You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tapestry.apache.org by be...@apache.org on 2026/08/08 10:56:47 UTC

(tapestry-5) 01/02: TAP5-2838: PipelineBuidler improvements

This is an automated email from the ASF dual-hosted git repository.

benweidig pushed a commit to branch TAP5-2838
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git

commit 338888f825478aab4b3337a6d843b06318174fdf
Author: Ben Weidig <be...@netzgut.net>
AuthorDate: Sat Aug 8 12:55:27 2026 +0200

    TAP5-2838: PipelineBuidler improvements
    
    Let pipelines describe themselves by implementing the new PipelineStep
    interface
---
 .../ioc/internal/services/BridgeBuilder.java       |  81 ++++++-
 .../ioc/internal/services/PipelineBuilderImpl.java |  36 +++
 .../ioc/internal/services/PipelineDescriber.java   |  92 ++++++++
 .../ioc/internal/services/PipelineStep.java        |  34 +++
 .../tapestry5/ioc/services/AssembledPipeline.java  |  92 ++++++++
 .../tapestry5/ioc/services/PipelineBuilder.java    |  14 ++
 .../test/groovy/ioc/specs/BridgeBuilderSpec.groovy | 178 --------------
 .../ioc/specs/PipelineBuilderImplSpec.groovy       |  88 -------
 .../ioc/internal/services/BridgeBuilderTest.java   | 258 +++++++++++++++++++++
 .../internal/services/PipelineBuilderImplTest.java | 241 +++++++++++++++++++
 .../apache/tapestry5/ioc/test/RecordingLogger.java | 177 ++++++++++++++
 11 files changed, 1023 insertions(+), 268 deletions(-)

diff --git a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilder.java b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilder.java
index 63f6255c5..64c7ac74c 100644
--- a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilder.java
+++ b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilder.java
@@ -27,12 +27,20 @@ import java.util.List;
  */
 public class BridgeBuilder<S, F>
 {
+    private static final MethodDescription GET_PIPELINE_FILTER = new MethodDescription("java.lang.Object",
+            "getPipelineFilter");
+
+    private static final MethodDescription GET_PIPELINE_NEXT = new MethodDescription("java.lang.Object",
+            "getPipelineNext");
+
     private final Logger logger;
 
     private final Class<S> serviceInterface;
 
     private final Class<F> filterInterface;
 
+    private final String header;
+
     private final FilterMethodAnalyzer filterMethodAnalyzer;
 
     private final PlasticProxyFactory proxyFactory;
@@ -47,6 +55,8 @@ public class BridgeBuilder<S, F>
 
         this.proxyFactory = proxyFactory;
 
+        header = PipelineDescriber.header(serviceInterface, filterInterface);
+
         filterMethodAnalyzer = new FilterMethodAnalyzer(serviceInterface);
     }
 
@@ -80,8 +90,75 @@ public class BridgeBuilder<S, F>
 
                 processMethods(plasticClass, filterField, nextField);
 
-                plasticClass.addToString(String.format("<PipelineBridge from %s to %s>", serviceInterface.getName(),
-                        filterInterface.getName()));
+                introducePipelineStep(plasticClass, filterField, nextField);
+            }
+        });
+    }
+
+    /**
+     * Makes the bridge a {@link PipelineStep}, exposing the filter it wraps and what it delegates to, and adds a
+     * {@code toString()} that describes the pipeline from this step onwards.
+     */
+    private void introducePipelineStep(PlasticClass plasticClass, final PlasticField filterField,
+                                       final PlasticField nextField)
+    {
+        // Rule out that a service interface declares one of the methods we want to introduce.
+        // Fall back to a plain description.
+
+        if (plasticClass.isMethodImplemented(GET_PIPELINE_FILTER) || plasticClass.isMethodImplemented(GET_PIPELINE_NEXT))
+        {
+            plasticClass.addToString(header);
+
+            return;
+        }
+
+        plasticClass.introduceInterface(PipelineStep.class);
+
+        plasticClass.introduceMethod(GET_PIPELINE_FILTER, new InstructionBuilderCallback()
+        {
+            @Override
+            public void doBuild(InstructionBuilder builder)
+            {
+                builder.loadThis().getField(filterField).returnResult();
+            }
+        });
+
+        plasticClass.introduceMethod(GET_PIPELINE_NEXT, new InstructionBuilderCallback()
+        {
+            @Override
+            public void doBuild(InstructionBuilder builder)
+            {
+                builder.loadThis().getField(nextField).returnResult();
+            }
+        });
+
+        addToString(plasticClass);
+    }
+
+    /**
+     * Adds a {@code toString()} that delegates to
+     * {@link PipelineDescriber#describe(String, PipelineStep)}, describing the whole chain from this bridge onwards.
+     *
+     * Nothing is added when {@code toString()} is part of the service interface itself; in that case the method is
+     * already bridged to the filter, and that takes precedence. The pipeline can still be walked as a
+     * {@link PipelineStep}.
+     */
+    private void addToString(PlasticClass plasticClass)
+    {
+        if (plasticClass.isMethodImplemented(PlasticUtils.TO_STRING_DESCRIPTION))
+            return;
+
+        plasticClass.introduceMethod(PlasticUtils.TO_STRING_DESCRIPTION, new InstructionBuilderCallback()
+        {
+            @Override
+            public void doBuild(InstructionBuilder builder)
+            {
+                builder.loadConstant(header).loadThis();
+
+                builder.invokeStatic(PipelineDescriber.class, String.class, "describe", String.class,
+                        PipelineStep.class);
+
+                builder.returnResult();
             }
         });
     }
diff --git a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImpl.java b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImpl.java
index a2d877a2e..8e088866d 100644
--- a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImpl.java
+++ b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImpl.java
@@ -14,9 +14,13 @@
 
 package org.apache.tapestry5.ioc.internal.services;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 import org.apache.tapestry5.commons.services.PlasticProxyFactory;
+import org.apache.tapestry5.commons.util.CollectionFactory;
+import org.apache.tapestry5.ioc.services.AssembledPipeline;
 import org.apache.tapestry5.ioc.services.Builtin;
 import org.apache.tapestry5.ioc.services.DefaultImplementationBuilder;
 import org.apache.tapestry5.ioc.services.PipelineBuilder;
@@ -29,6 +33,8 @@ public class PipelineBuilderImpl implements PipelineBuilder
 
     private final DefaultImplementationBuilder defaultImplementationBuilder;
 
+    private final List<AssembledPipeline> assembledPipelines = CollectionFactory.newThreadSafeList();
+
     public PipelineBuilderImpl(@Builtin
     PlasticProxyFactory proxyFactory,
 
@@ -50,6 +56,8 @@ public class PipelineBuilderImpl implements PipelineBuilder
     public <S, F> S build(Logger logger, Class<S> serviceInterface, Class<F> filterInterface, List<F> filters,
             S terminator)
     {
+        track(serviceInterface, filterInterface, filters, terminator);
+
         if (filters.isEmpty())
             return terminator;
 
@@ -69,7 +77,35 @@ public class PipelineBuilderImpl implements PipelineBuilder
             next = bb.instantiateBridge(next, filter);
         }
 
+        if (logger.isDebugEnabled())
+        {
+            logger.debug("Assembled pipeline: {}", next);
+        }
+
         return next;
     }
 
+    @Override
+    public List<AssembledPipeline> getAssembledPipelines()
+    {
+        return Collections.unmodifiableList(assembledPipelines);
+    }
+
+    /**
+     * Records the pipeline for later introspection, describing the filters and the terminator as they are now rather
+     * than holding on to them.
+     */
+    private <S, F> void track(Class<S> serviceInterface, Class<F> filterInterface, List<F> filters, S terminator)
+    {
+        List<String> descriptions = new ArrayList<>(filters.size());
+
+        for (F filter : filters)
+        {
+            descriptions.add(String.valueOf(filter));
+        }
+
+        assembledPipelines.add(new AssembledPipeline(serviceInterface, filterInterface, descriptions,
+                String.valueOf(terminator)));
+    }
+
 }
diff --git a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineDescriber.java b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineDescriber.java
new file mode 100644
index 000000000..7209b726f
--- /dev/null
+++ b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineDescriber.java
@@ -0,0 +1,92 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.internal.services;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Describes an assembled pipeline as {@code header: filter -&gt; filter -&gt; terminator}.
+ *
+ * <p>The {@code toString()} of the bridge classes generated by {@link BridgeBuilder} delegates here,
+ * as does {@link org.apache.tapestry5.ioc.services.AssembledPipeline}.
+ * @since 5.10.0
+ */
+public final class PipelineDescriber
+{
+    private static final String SEPARATOR = " -> ";
+
+    private PipelineDescriber()
+    {
+    }
+
+    /**
+     * The header identifying a pipeline, ahead of its steps.
+     */
+    public static String header(Class<?> serviceInterface, Class<?> filterInterface)
+    {
+        return String.format("<PipelineBridge from %s to %s>", serviceInterface.getName(), filterInterface.getName());
+    }
+
+    /**
+     * Walks the pipeline from the given step to its end, describing each filter, including the terminator.
+     *
+     * @param header
+     *         identifies the pipeline, the steps follow it, separated by a colon
+     * @param step
+     *         where to start, typically the outermost bridge, but any step describes the pipeline from itself
+     *         onwards
+     */
+    public static String describe(String header, PipelineStep step)
+    {
+        List<Object> steps = new ArrayList<>();
+
+        Object current = step;
+
+        while (current instanceof PipelineStep)
+        {
+            PipelineStep pipelineStep = (PipelineStep) current;
+
+            steps.add(pipelineStep.getPipelineFilter());
+
+            current = pipelineStep.getPipelineNext();
+        }
+
+        steps.add(current);
+
+        return describe(header, steps);
+    }
+
+    /**
+     * Describes steps that have already been collected: the filters, in the order they are invoked, followed by the
+     * terminator.
+     */
+    public static String describe(String header, List<?> steps)
+    {
+        StringBuilder builder = new StringBuilder(header).append(": ");
+
+        for (int i = 0; i < steps.size(); i++)
+        {
+            if (i > 0)
+            {
+                builder.append(SEPARATOR);
+            }
+
+            builder.append(steps.get(i));
+        }
+
+        return builder.toString();
+    }
+}
diff --git a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineStep.java b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineStep.java
new file mode 100644
index 000000000..b61e04278
--- /dev/null
+++ b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineStep.java
@@ -0,0 +1,34 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.internal.services;
+
+/**
+ * Implemented by the bridge classes generated by {@link BridgeBuilder},
+ * to expose one step of an assembled pipeline.
+ *
+ * @since 5.10.0
+ */
+public interface PipelineStep
+{
+    /**
+     * The filter invoked at this step of the pipeline.
+     */
+    Object getPipelineFilter();
+
+    /**
+     * What this step delegates to: the next {@link PipelineStep}, or the terminator at the end of the pipeline.
+     */
+    Object getPipelineNext();
+}
diff --git a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/AssembledPipeline.java b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/AssembledPipeline.java
new file mode 100644
index 000000000..543f33b0e
--- /dev/null
+++ b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/AssembledPipeline.java
@@ -0,0 +1,92 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.services;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.tapestry5.ioc.internal.services.PipelineDescriber;
+
+/**
+ * A record of a pipeline assembled by {@link PipelineBuilder}: the service and filter interfaces it bridges,
+ * the filters in the order they are invoked, and the terminator at the end of the chain.
+ *
+ * The filters and the terminator are recorded as descriptions, taken when the pipeline was assembled,
+ * rather than as objects, so that a pipeline no longer in use is not kept alive by this record.
+ *
+ * @since 5.10.0
+ */
+public final class AssembledPipeline
+{
+    private final Class<?> serviceInterface;
+
+    private final Class<?> filterInterface;
+
+    private final List<String> filters;
+
+    private final String terminator;
+
+    public AssembledPipeline(Class<?> serviceInterface, Class<?> filterInterface, List<String> filters,
+                             String terminator)
+    {
+        this.serviceInterface = serviceInterface;
+        this.filterInterface = filterInterface;
+        this.filters = Collections.unmodifiableList(new ArrayList<>(filters));
+        this.terminator = terminator;
+    }
+
+    /**
+     * The interface the pipeline implements.
+     */
+    public Class<?> getServiceInterface()
+    {
+        return serviceInterface;
+    }
+
+    /**
+     * The interface implemented by each filter of the pipeline.
+     */
+    public Class<?> getFilterInterface()
+    {
+        return filterInterface;
+    }
+
+    /**
+     * The filters, in the order they are invoked. Empty when the pipeline is just its terminator.
+     */
+    public List<String> getFilters()
+    {
+        return filters;
+    }
+
+    /**
+     * What the last filter delegates to.
+     */
+    public String getTerminator()
+    {
+        return terminator;
+    }
+
+    @Override
+    public String toString()
+    {
+        List<String> steps = new ArrayList<>(filters);
+
+        steps.add(terminator);
+
+        return PipelineDescriber.describe(PipelineDescriber.header(serviceInterface, filterInterface), steps);
+    }
+}
diff --git a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/PipelineBuilder.java b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/PipelineBuilder.java
index 2cfa200e8..b272fddd5 100644
--- a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/PipelineBuilder.java
+++ b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/PipelineBuilder.java
@@ -14,6 +14,7 @@ package org.apache.tapestry5.ioc.services;
 
 import org.slf4j.Logger;
 
+import java.util.Collections;
 import java.util.List;
 
 /**
@@ -63,4 +64,17 @@ public interface PipelineBuilder
      */
     <S, F> S build(Logger logger, Class<S> serviceInterface, Class<F> filterInterface, List<F> filters);
 
+    /**
+     * The pipelines assembled so far, in the order they were assembled.
+     *
+     * <p>A pipeline is assembled when the service it implements is first realized, so the list grows as the application runs.
+     *
+     * @return the assembled pipelines, or an empty list for an implementation that does not keep track of them
+     * @since 5.10.0
+     */
+    default List<AssembledPipeline> getAssembledPipelines()
+    {
+        return Collections.emptyList();
+    }
+
 }
diff --git a/tapestry-ioc/src/test/groovy/ioc/specs/BridgeBuilderSpec.groovy b/tapestry-ioc/src/test/groovy/ioc/specs/BridgeBuilderSpec.groovy
deleted file mode 100644
index 57339a1e9..000000000
--- a/tapestry-ioc/src/test/groovy/ioc/specs/BridgeBuilderSpec.groovy
+++ /dev/null
@@ -1,178 +0,0 @@
-package ioc.specs
-
-import org.apache.tapestry5.commons.services.PlasticProxyFactory
-import org.apache.tapestry5.ioc.internal.services.*
-import org.apache.tapestry5.ioc.test.internal.services.ExtraFilterMethod
-import org.apache.tapestry5.ioc.test.internal.services.ExtraServiceMethod
-import org.apache.tapestry5.ioc.test.internal.services.MiddleFilter
-import org.apache.tapestry5.ioc.test.internal.services.MiddleService
-import org.apache.tapestry5.ioc.test.internal.services.StandardFilter
-import org.apache.tapestry5.ioc.test.internal.services.StandardService
-import org.apache.tapestry5.ioc.test.internal.services.ToStringFilter
-import org.apache.tapestry5.ioc.test.internal.services.ToStringService
-import org.slf4j.Logger
-
-import spock.lang.Shared
-
-class BridgeBuilderSpec extends AbstractSharedRegistrySpecification {
-
-  @Shared
-  PlasticProxyFactory proxyFactory;
-
-  def setupSpec() {
-    proxyFactory = getService PlasticProxyFactory
-  }
-
-  def "toString() of proxy is as expected"() {
-    Logger logger = Mock()
-    StandardFilter sf = Mock()
-    StandardService ss = Mock()
-
-    BridgeBuilder builder = new BridgeBuilder(logger, StandardService, StandardFilter, proxyFactory)
-
-    when:
-
-    def bridge = builder.instantiateBridge(ss, sf)
-
-    then:
-
-    bridge.toString() == "<PipelineBridge from org.apache.tapestry5.ioc.test.internal.services.StandardService to org.apache.tapestry5.ioc.test.internal.services.StandardFilter>"
-  }
-
-  def "standard service and interface"() {
-    Logger logger = Mock()
-    StandardFilter sf = Mock()
-    StandardService ss = Mock()
-
-    BridgeBuilder builder = new BridgeBuilder(logger, StandardService, StandardFilter, proxyFactory)
-    def bridge = builder.instantiateBridge(ss, sf)
-
-    when:
-
-    assert bridge.run(5) == 18
-
-    // 18 =  3 * (5 + 1)
-    // so the filter runs first, and passes 6 to the service
-    // seems there's an issue in Spock with chaining mocks this way
-
-    then:
-
-    1 * sf.run(_, _) >> { i, service -> service.run(i + 1) }
-
-    1 * ss.run(_) >> { i -> 3 * i }
-
-    0 * _
-  }
-
-  def "when toString() is part of service interface, it is forwarded through the filter"() {
-    Logger logger = Mock()
-
-    ToStringService service = new ToStringService() {
-
-      String toString() { "Service" }
-    }
-
-    ToStringFilter filter = new ToStringFilter() {
-
-      String toString(ToStringService s) {
-        s.toString().toUpperCase()
-      }
-    }
-
-    BridgeBuilder builder = new BridgeBuilder(logger, ToStringService, ToStringFilter, proxyFactory)
-
-    when:
-
-    ToStringService bridge = builder.instantiateBridge(service, filter)
-
-    then:
-
-    bridge.toString() == "SERVICE"
-  }
-
-  def "unmatched service interface method is logged and exception thrown"() {
-    Logger logger = Mock()
-    ExtraServiceMethod next = Mock()
-    Serializable filter = Mock()
-
-    BridgeBuilder builder = new BridgeBuilder(logger, ExtraServiceMethod, Serializable, proxyFactory)
-
-    when:
-
-    ExtraServiceMethod esm = builder.instantiateBridge(next, filter)
-
-    then:
-
-    1 * logger.error("Method void extraServiceMethod() has no match in filter interface java.io.Serializable.")
-
-    when:
-
-    esm.extraServiceMethod()
-
-    then:
-
-    RuntimeException e = thrown()
-
-    e.message == "Method void extraServiceMethod() has no match in filter interface java.io.Serializable."
-  }
-
-  def "extra methods in filter interface are logged and ignored"() {
-    Logger logger = Mock()
-    Serializable next = Mock()
-    ExtraFilterMethod filter = Mock()
-
-    BridgeBuilder builder = new BridgeBuilder(logger, Serializable, ExtraFilterMethod, proxyFactory)
-
-    when:
-
-    assert builder.instantiateBridge(next, filter) != null
-
-    then:
-
-    1 * logger.error('Method {} of filter interface {} does not have a matching method in {}.', { it.name == "extraFilterMethod" }, ExtraFilterMethod.name, Serializable.name)
-
-    0 * _
-  }
-
-  def "the service parameter may be a middle parameter of the filter method"() {
-    Logger logger = Mock()
-
-    MiddleFilter mf = new MiddleFilter() {
-
-      @Override
-      void execute(int count, char ch, MiddleService service, StringBuilder buffer) {
-        service.execute(count, ch, buffer)
-
-        buffer.append(' ')
-
-        service.execute(count + 1, Character.toUpperCase(ch), buffer)
-      }
-    }
-
-    MiddleService ms = new MiddleService() {
-
-      @Override
-      void execute(int count, char ch, StringBuilder buffer) {
-        count.times() { buffer.append ch }
-      }
-    }
-
-    BridgeBuilder builder = new BridgeBuilder(logger, MiddleService, MiddleFilter, proxyFactory)
-
-
-    MiddleService bridge = builder.instantiateBridge(ms, mf)
-
-    StringBuilder buffer = new StringBuilder("CODE: ")
-
-    when:
-
-
-    bridge.execute(3, 'a' as char, buffer)
-
-    then:
-
-    buffer.toString() == "CODE: aaa AAAA"
-
-  }
-
-}
diff --git a/tapestry-ioc/src/test/groovy/ioc/specs/PipelineBuilderImplSpec.groovy b/tapestry-ioc/src/test/groovy/ioc/specs/PipelineBuilderImplSpec.groovy
deleted file mode 100644
index f543c28de..000000000
--- a/tapestry-ioc/src/test/groovy/ioc/specs/PipelineBuilderImplSpec.groovy
+++ /dev/null
@@ -1,88 +0,0 @@
-package ioc.specs
-
-import org.apache.tapestry5.ioc.services.PipelineBuilder
-import org.apache.tapestry5.ioc.test.internal.services.StandardFilter
-import org.apache.tapestry5.ioc.test.internal.services.StandardService
-import org.slf4j.Logger
-
-import spock.lang.Shared
-
-class PipelineBuilderImplSpec extends AbstractSharedRegistrySpecification {
-
-  @Shared
-  PipelineBuilder builder
-
-  def setupSpec() { builder = getService PipelineBuilder }
-
-  Logger logger = Mock()
-
-  def "standard pipeline with filters"() {
-
-    // For some reason, this didn't work with closures, just with actual inner classes
-
-    StandardFilter subtracter = new StandardFilter() {
-
-      @Override
-      int run(int i, StandardService service) {
-        service.run(i) - 2
-      }
-    }
-
-    StandardFilter multiplier = new StandardFilter() {
-
-      @Override
-      int run(int i, StandardService service) {
-        2 * service.run(i)
-      }
-    }
-
-    StandardFilter adder = new StandardFilter() {
-
-      @Override
-      int run(int i, StandardService service) {
-        service.run(i + 3)
-      }
-    }
-
-    StandardService terminator = new StandardService() {
-
-      @Override
-      int run(int i) {
-        i
-      }
-    }
-
-    when:
-
-    StandardService pipeline = builder.build logger, StandardService, StandardFilter, [subtracter, multiplier, adder], terminator
-
-    then:
-
-    pipeline.run(5) == 14
-    pipeline.run(10) == 24
-  }
-
-  def "a pipeline without filters is simply the temrinator"() {
-
-    StandardService terminator = Mock()
-
-    when:
-
-    StandardService pipeline = builder.build logger, StandardService, StandardFilter, [], terminator
-
-    then:
-
-    pipeline.is terminator
-  }
-
-  def "a pipeline with no filters and no terminator does nothing"() {
-    when:
-
-    StandardService pipeline = builder.build logger, StandardService, StandardFilter, []
-
-    then:
-
-    pipeline.run(99) == 0
-
-  }
-}
diff --git a/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilderTest.java b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilderTest.java
new file mode 100644
index 000000000..6dd3d2e43
--- /dev/null
+++ b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilderTest.java
@@ -0,0 +1,258 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.internal.services;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.tapestry5.beanmodel.services.PlasticProxyFactoryImpl;
+import org.apache.tapestry5.commons.services.PlasticProxyFactory;
+import org.apache.tapestry5.ioc.test.RecordingLogger;
+import org.apache.tapestry5.ioc.test.internal.services.ExtraFilterMethod;
+import org.apache.tapestry5.ioc.test.internal.services.ExtraServiceMethod;
+import org.apache.tapestry5.ioc.test.internal.services.MiddleFilter;
+import org.apache.tapestry5.ioc.test.internal.services.MiddleService;
+import org.apache.tapestry5.ioc.test.internal.services.StandardFilter;
+import org.apache.tapestry5.ioc.test.internal.services.StandardService;
+import org.apache.tapestry5.ioc.test.internal.services.ToStringFilter;
+import org.apache.tapestry5.ioc.test.internal.services.ToStringService;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.event.Level;
+
+class BridgeBuilderTest
+{
+    private static final String STANDARD_SERVICE = StandardService.class.getName();
+
+    private static final String STANDARD_FILTER = StandardFilter.class.getName();
+
+    private static PlasticProxyFactory proxyFactory;
+
+    private RecordingLogger logger;
+
+    @BeforeAll
+    static void createProxyFactory()
+    {
+        proxyFactory = new PlasticProxyFactoryImpl(BridgeBuilderTest.class.getClassLoader(), null);
+    }
+
+    @BeforeEach
+    void createLogger()
+    {
+        logger = new RecordingLogger();
+    }
+
+    @Test
+    void toStringOfBridgeDescribesThePipeline()
+    {
+        StandardFilter filter = new StandardFilter()
+        {
+            @Override
+            public int run(int i, StandardService service)
+            {
+                return service.run(i);
+            }
+
+            @Override
+            public String toString()
+            {
+                return "FILTER";
+            }
+        };
+
+        StandardService next = new StandardService()
+        {
+            @Override
+            public int run(int i)
+            {
+                return i;
+            }
+
+            @Override
+            public String toString()
+            {
+                return "NEXT";
+            }
+        };
+
+        BridgeBuilder<StandardService, StandardFilter> builder = new BridgeBuilder<>(logger, StandardService.class,
+                StandardFilter.class, proxyFactory);
+
+        StandardService bridge = builder.instantiateBridge(next, filter);
+
+        assertEquals(String.format("<PipelineBridge from %s to %s>: FILTER -> NEXT", STANDARD_SERVICE, STANDARD_FILTER),
+                bridge.toString());
+    }
+
+    @Test
+    void bridgeExposesItsFilterAndItsNextStep()
+    {
+        StandardFilter filter = (i, service) -> service.run(i);
+
+        StandardService next = i -> i;
+
+        BridgeBuilder<StandardService, StandardFilter> builder = new BridgeBuilder<>(logger, StandardService.class,
+                StandardFilter.class, proxyFactory);
+
+        PipelineStep bridge = (PipelineStep) builder.instantiateBridge(next, filter);
+
+        assertSame(filter, bridge.getPipelineFilter());
+        assertSame(next, bridge.getPipelineNext());
+    }
+
+    @Test
+    void standardServiceAndInterface()
+    {
+        AtomicInteger filterCount = new AtomicInteger();
+        AtomicInteger serviceCount = new AtomicInteger();
+
+        // The filter runs first and passes 6 to the service, so the result is 3 * (5 + 1).
+
+        StandardFilter filter = (i, service) ->
+        {
+            filterCount.incrementAndGet();
+
+            return service.run(i + 1);
+        };
+
+        StandardService next = i ->
+        {
+            serviceCount.incrementAndGet();
+
+            return 3 * i;
+        };
+
+        BridgeBuilder<StandardService, StandardFilter> builder = new BridgeBuilder<>(logger, StandardService.class,
+                StandardFilter.class, proxyFactory);
+
+        StandardService bridge = builder.instantiateBridge(next, filter);
+
+        assertEquals(18, bridge.run(5));
+
+        assertEquals(1, filterCount.get());
+        assertEquals(1, serviceCount.get());
+    }
+
+    @Test
+    void whenToStringIsPartOfServiceInterfaceItIsForwardedThroughTheFilter()
+    {
+        ToStringService service = new ToStringService()
+        {
+            @Override
+            public String toString()
+            {
+                return "Service";
+            }
+        };
+
+        ToStringFilter filter = service1 -> service1.toString().toUpperCase();
+
+        BridgeBuilder<ToStringService, ToStringFilter> builder = new BridgeBuilder<>(logger, ToStringService.class,
+                ToStringFilter.class, proxyFactory);
+
+        ToStringService bridge = builder.instantiateBridge(service, filter);
+
+        assertEquals("SERVICE", bridge.toString());
+
+        // toString() is taken by the service interface, but the pipeline can still be walked.
+
+        assertSame(filter, ((PipelineStep) bridge).getPipelineFilter());
+        assertSame(service, ((PipelineStep) bridge).getPipelineNext());
+    }
+
+    @Test
+    void unmatchedServiceInterfaceMethodIsLoggedAndExceptionThrown()
+    {
+        String expectedMessage = "Method void extraServiceMethod() has no match in filter interface java.io.Serializable.";
+
+        ExtraServiceMethod next = () ->
+        {
+        };
+
+        Serializable filter = new Serializable()
+        {
+        };
+
+        BridgeBuilder<ExtraServiceMethod, Serializable> builder = new BridgeBuilder<>(logger, ExtraServiceMethod.class,
+                Serializable.class, proxyFactory);
+
+        ExtraServiceMethod bridge = builder.instantiateBridge(next, filter);
+
+        assertEquals(Collections.singletonList(expectedMessage), logger.getMessages(Level.ERROR));
+
+        RuntimeException e = assertThrows(RuntimeException.class, bridge::extraServiceMethod);
+
+        assertEquals(expectedMessage, e.getMessage());
+    }
+
+    @Test
+    void extraMethodsInFilterInterfaceAreLoggedAndIgnored()
+    {
+        Serializable next = new Serializable()
+        {
+        };
+
+        ExtraFilterMethod filter = () ->
+        {
+        };
+
+        BridgeBuilder<Serializable, ExtraFilterMethod> builder = new BridgeBuilder<>(logger, Serializable.class,
+                ExtraFilterMethod.class, proxyFactory);
+
+        assertNotNull(builder.instantiateBridge(next, filter));
+
+        assertEquals(Collections.singletonList(String.format(
+                "Method void extraFilterMethod() of filter interface %s does not have a matching method in %s.",
+                ExtraFilterMethod.class.getName(), Serializable.class.getName())), logger.getMessages(Level.ERROR));
+    }
+
+    @Test
+    void theServiceParameterMayBeAMiddleParameterOfTheFilterMethod()
+    {
+        MiddleFilter filter = (count, ch, service, buffer) ->
+        {
+            service.execute(count, ch, buffer);
+
+            buffer.append(' ');
+
+            service.execute(count + 1, Character.toUpperCase(ch), buffer);
+        };
+
+        MiddleService next = (count, ch, buffer) ->
+        {
+            for (int i = 0; i < count; i++)
+            {
+                buffer.append(ch);
+            }
+        };
+
+        BridgeBuilder<MiddleService, MiddleFilter> builder = new BridgeBuilder<>(logger, MiddleService.class,
+                MiddleFilter.class, proxyFactory);
+
+        MiddleService bridge = builder.instantiateBridge(next, filter);
+
+        StringBuilder buffer = new StringBuilder("CODE: ");
+
+        bridge.execute(3, 'a', buffer);
+
+        assertEquals("CODE: aaa AAAA", buffer.toString());
+    }
+}
diff --git a/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImplTest.java b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImplTest.java
new file mode 100644
index 000000000..952abaace
--- /dev/null
+++ b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImplTest.java
@@ -0,0 +1,241 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.internal.services;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.tapestry5.beanmodel.services.PlasticProxyFactoryImpl;
+import org.apache.tapestry5.commons.services.PlasticProxyFactory;
+import org.apache.tapestry5.ioc.services.AssembledPipeline;
+import org.apache.tapestry5.ioc.services.PipelineBuilder;
+import org.apache.tapestry5.ioc.test.RecordingLogger;
+import org.apache.tapestry5.ioc.test.internal.services.StandardFilter;
+import org.apache.tapestry5.ioc.test.internal.services.StandardService;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.event.Level;
+
+class PipelineBuilderImplTest
+{
+    private static final String STANDARD_SERVICE = StandardService.class.getName();
+
+    private static final String STANDARD_FILTER = StandardFilter.class.getName();
+
+    private static PlasticProxyFactory proxyFactory;
+
+    private PipelineBuilder builder;
+
+    private RecordingLogger logger;
+
+    @BeforeAll
+    static void createProxyFactory()
+    {
+        proxyFactory = new PlasticProxyFactoryImpl(PipelineBuilderImplTest.class.getClassLoader(), null);
+    }
+
+    /**
+     * A fresh builder for each test, since a builder keeps track of the pipelines it has assembled.
+     */
+    @BeforeEach
+    void createBuilder()
+    {
+        builder = new PipelineBuilderImpl(proxyFactory, new DefaultImplementationBuilderImpl(proxyFactory));
+
+        logger = new RecordingLogger();
+    }
+
+    /**
+     * A filter that delegates to the next service, with a fixed {@code toString()} so that the description of the
+     * assembled pipeline is predictable.
+     */
+    private static StandardFilter namedFilter(final String name)
+    {
+        return new StandardFilter()
+        {
+            @Override
+            public int run(int i, StandardService service)
+            {
+                return service.run(i);
+            }
+
+            @Override
+            public String toString()
+            {
+                return name;
+            }
+        };
+    }
+
+    /**
+     * A terminator with a fixed {@code toString()}, for the same reason.
+     */
+    private static StandardService namedTerminator(final String name)
+    {
+        return new StandardService()
+        {
+            @Override
+            public int run(int i)
+            {
+                return i;
+            }
+
+            @Override
+            public String toString()
+            {
+                return name;
+            }
+        };
+    }
+
+    @Test
+    void standardPipelineWithFilters()
+    {
+        StandardFilter subtracter = (i, service) -> service.run(i) - 2;
+
+        StandardFilter multiplier = (i, service) -> 2 * service.run(i);
+
+        StandardFilter adder = (i, service) -> service.run(i + 3);
+
+        StandardService terminator = i -> i;
+
+        StandardService pipeline = builder.build(logger, StandardService.class, StandardFilter.class,
+                Arrays.asList(subtracter, multiplier, adder), terminator);
+
+        assertEquals(14, pipeline.run(5));
+        assertEquals(24, pipeline.run(10));
+    }
+
+    @Test
+    void aPipelineWithoutFiltersIsSimplyTheTerminator()
+    {
+        StandardService terminator = i -> i;
+
+        StandardService pipeline = builder.build(logger, StandardService.class, StandardFilter.class,
+                Collections.emptyList(), terminator);
+
+        assertSame(terminator, pipeline);
+    }
+
+    @Test
+    void aPipelineWithNoFiltersAndNoTerminatorDoesNothing()
+    {
+        StandardService pipeline = builder.build(logger, StandardService.class, StandardFilter.class,
+                Collections.emptyList());
+
+        assertEquals(0, pipeline.run(99));
+    }
+
+    @Test
+    void toStringOfPipelineDescribesTheWholeChain()
+    {
+        StandardService pipeline = builder.build(logger, StandardService.class, StandardFilter.class,
+                Arrays.asList(namedFilter("FIRST"), namedFilter("SECOND")), namedTerminator("TERMINATOR"));
+
+        assertEquals(String.format("<PipelineBridge from %s to %s>: FIRST -> SECOND -> TERMINATOR", STANDARD_SERVICE,
+                STANDARD_FILTER), pipeline.toString());
+    }
+
+    @Test
+    void everyStepOfThePipelineDescribesItselfOnwards()
+    {
+        StandardService pipeline = builder.build(logger, StandardService.class, StandardFilter.class,
+                Arrays.asList(namedFilter("FIRST"), namedFilter("SECOND")), namedTerminator("TERMINATOR"));
+
+        Object secondStep = ((PipelineStep) pipeline).getPipelineNext();
+
+        assertEquals(String.format("<PipelineBridge from %s to %s>: SECOND -> TERMINATOR", STANDARD_SERVICE,
+                STANDARD_FILTER), secondStep.toString());
+    }
+
+    @Test
+    void thePipelineExposesItsFiltersInOrder()
+    {
+        StandardFilter first = (i, service) -> service.run(i);
+
+        StandardFilter second = (i, service) -> service.run(i);
+
+        StandardService terminator = i -> i;
+
+        StandardService pipeline = builder.build(logger, StandardService.class, StandardFilter.class,
+                Arrays.asList(first, second), terminator);
+
+        PipelineStep firstStep = (PipelineStep) pipeline;
+
+        assertSame(first, firstStep.getPipelineFilter());
+
+        PipelineStep secondStep = (PipelineStep) firstStep.getPipelineNext();
+
+        assertSame(second, secondStep.getPipelineFilter());
+        assertSame(terminator, secondStep.getPipelineNext());
+    }
+
+    @Test
+    void toStringOfPipelineEndsWithTheDefaultTerminator()
+    {
+        StandardService pipeline = builder.build(logger, StandardService.class, StandardFilter.class,
+                Collections.singletonList(namedFilter("ONLY")));
+
+        assertEquals(String.format("<PipelineBridge from %s to %s>: ONLY -> <NoOp %s>", STANDARD_SERVICE,
+                STANDARD_FILTER, STANDARD_SERVICE), pipeline.toString());
+    }
+
+    @Test
+    void assembledPipelinesAreTracked()
+    {
+        builder.build(logger, StandardService.class, StandardFilter.class,
+                Arrays.asList(namedFilter("FIRST"), namedFilter("SECOND")), namedTerminator("TERMINATOR"));
+
+        List<AssembledPipeline> pipelines = builder.getAssembledPipelines();
+
+        assertEquals(1, pipelines.size());
+
+        AssembledPipeline pipeline = pipelines.get(0);
+
+        assertEquals(StandardService.class, pipeline.getServiceInterface());
+        assertEquals(StandardFilter.class, pipeline.getFilterInterface());
+        assertEquals(Arrays.asList("FIRST", "SECOND"), pipeline.getFilters());
+        assertEquals("TERMINATOR", pipeline.getTerminator());
+
+        assertEquals(String.format("<PipelineBridge from %s to %s>: FIRST -> SECOND -> TERMINATOR", STANDARD_SERVICE,
+                STANDARD_FILTER), pipeline.toString());
+    }
+
+    @Test
+    void aPipelineWithoutFiltersIsTrackedAsWell()
+    {
+        builder.build(logger, StandardService.class, StandardFilter.class, Collections.emptyList(),
+                namedTerminator("TERMINATOR"));
+
+        AssembledPipeline pipeline = builder.getAssembledPipelines().get(0);
+
+        assertTrue(pipeline.getFilters().isEmpty());
+        assertEquals("TERMINATOR", pipeline.getTerminator());
+    }
+
+    @Test
+    void theAssembledPipelineIsLoggedAtDebugLevel()
+    {
+        StandardService pipeline = builder.build(logger, StandardService.class, StandardFilter.class,
+                Collections.singletonList(namedFilter("ONLY")));
+
+        assertEquals(Collections.singletonList("Assembled pipeline: " + pipeline), logger.getMessages(Level.DEBUG));
+    }
+}
diff --git a/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/test/RecordingLogger.java b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/test/RecordingLogger.java
new file mode 100644
index 000000000..4103e87ef
--- /dev/null
+++ b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/test/RecordingLogger.java
@@ -0,0 +1,177 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Marker;
+import org.slf4j.event.Level;
+import org.slf4j.helpers.AbstractLogger;
+import org.slf4j.helpers.MessageFormatter;
+
+/**
+ * A Logger that records every logging call, for tests that need to assert what was logged. Every level is
+ * enabled, so nothing is filtered out before it can be recorded.
+ */
+public class RecordingLogger extends AbstractLogger
+{
+    private static final long serialVersionUID = 1L;
+
+    private final List<Event> events = new ArrayList<>();
+
+    /**
+     * A single recorded logging call, with its message already formatted.
+     */
+    public static final class Event
+    {
+        private final Level level;
+
+        private final String message;
+
+        Event(Level level, String message)
+        {
+            this.level = level;
+            this.message = message;
+        }
+
+        public Level getLevel()
+        {
+            return level;
+        }
+
+        public String getMessage()
+        {
+            return message;
+        }
+
+        @Override
+        public String toString()
+        {
+            return level + " " + message;
+        }
+    }
+
+    public RecordingLogger()
+    {
+        this.name = RecordingLogger.class.getName();
+    }
+
+    /**
+     * All recorded events, in the order they were logged.
+     */
+    public List<Event> getEvents()
+    {
+        return new ArrayList<>(events);
+    }
+
+    /**
+     * The formatted messages recorded at the given level, in the order they were logged.
+     */
+    public List<String> getMessages(Level level)
+    {
+        List<String> result = new ArrayList<>();
+
+        for (Event event : events)
+        {
+            if (event.getLevel() == level)
+            {
+                result.add(event.getMessage());
+            }
+        }
+
+        return result;
+    }
+
+    public void clear()
+    {
+        events.clear();
+    }
+
+    @Override
+    protected String getFullyQualifiedCallerName()
+    {
+        return null;
+    }
+
+    @Override
+    protected void handleNormalizedLoggingCall(Level level, Marker marker, String messagePattern, Object[] arguments,
+                                               Throwable throwable)
+    {
+        String message = arguments == null ? messagePattern : MessageFormatter.basicArrayFormat(messagePattern,
+                arguments);
+
+        events.add(new Event(level, message));
+    }
+
+    @Override
+    public boolean isTraceEnabled()
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isTraceEnabled(Marker marker)
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isDebugEnabled()
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isDebugEnabled(Marker marker)
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isInfoEnabled()
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isInfoEnabled(Marker marker)
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isWarnEnabled()
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isWarnEnabled(Marker marker)
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isErrorEnabled()
+    {
+        return true;
+    }
+
+    @Override
+    public boolean isErrorEnabled(Marker marker)
+    {
+        return true;
+    }
+}