This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 21f5389e0e2242eaea1e17000a0d8e838cf4e153 Author: James Bognar <[email protected]> AuthorDate: Fri Jul 24 16:52:44 2026 -0400 Add @Child annotation for host-side seeding of child REST contexts (TODO-182) Adds @Child and @Rest(childrenDefs=...) mirroring @Mixin/mixinDefs, letting a host seed a curated set of settings onto a routed child's otherwise-isolated RestContext. Additive-security attributes (guards, converters, roleGuard, rolesDeclared) prepend/AND-stack; child-wins scalars (callLogger, partSerializer, partParser, debug, defaultCharset, maxInput) act as fallbacks. Seeds never override a child's own explicit config; a child's noInherit cuts the seed. Includes lazy-children seed threading and a fix to computeRawRestAnnotations() DefaultConfig partitioning. Also includes 7 incidental java:S2259 false-positive suppressions in RestContext.java. Co-authored-by: Cursor <[email protected]> --- .../juneau/rest/child/ChildContext_Seed_Test.java | 147 +++++++++++ .../rest/child/ChildDefs_Discovery_Test.java | 70 +++++ .../rest/child/ChildDefs_Equivalence_Test.java | 56 ++++ .../child/ChildInheritance_Converters_Test.java | 84 ++++++ .../rest/child/ChildInheritance_Debug_Test.java | 105 ++++++++ .../ChildInheritance_DefaultConfigChild_Test.java | 110 ++++++++ .../rest/child/ChildInheritance_Guards_Test.java | 124 +++++++++ .../child/ChildInheritance_NoInherit_Test.java | 95 +++++++ .../child/ChildInheritance_PartParser_Test.java | 88 +++++++ .../ChildInheritance_PartSerializer_Test.java | 88 +++++++ .../child/ChildInheritance_RoleGuard_Test.java | 95 +++++++ .../rest/child/ChildInheritance_Scalars_Test.java | 114 ++++++++ .../juneau/rest/server/ChildAnnotation_Test.java | 118 +++++++++ .../rest/server/LazyChildren_ChildDefs_Test.java | 78 ++++++ .../java/org/apache/juneau/rest/server/Child.java | 165 ++++++++++++ .../apache/juneau/rest/server/ChildAnnotation.java | 225 ++++++++++++++++ .../java/org/apache/juneau/rest/server/Rest.java | 41 +++ .../apache/juneau/rest/server/RestAnnotation.java | 19 ++ .../apache/juneau/rest/server/RestChildren.java | 48 +++- .../org/apache/juneau/rest/server/RestContext.java | 290 ++++++++++++++++++--- .../juneau/rest/server/RestServerConstants.java | 3 + 21 files changed, 2120 insertions(+), 43 deletions(-) diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildContext_Seed_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildContext_Seed_Test.java new file mode 100644 index 0000000000..5e51695ecb --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildContext_Seed_Test.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.apache.juneau.commons.inject.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.guard.*; +import org.apache.juneau.rest.server.logger.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Phase 2 canary — proves the {@code @Child} seed-injection mechanism end-to-end using {@code callLogger} + * (a single child-wins scalar) as a witness, mirroring {@code MixinInheritance_CallLogger_Test}. + * + * <p> + * {@code callLogger} is chosen because {@link RestContext#getCallLogger()} is a direct, public, class-level + * getter with no request/op machinery needed to observe it. + */ +class ChildContext_Seed_Test extends TestBase { + + public static class SeedLogger extends CallLogger { + public SeedLogger(BeanStore bs) { super(bs); } + } + + public static class ChildLogger extends CallLogger { + public ChildLogger(BeanStore bs) { super(bs); } + } + + public static class BlockAllGuard extends RestGuard { + @Override public boolean isRequestAllowed(RestRequest req) { return false; } + } + + //------------------------------------------------------------------------------------------------------------ + // a01: child declares no callLogger -> host's @Child(callLogger=...) seed takes effect. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/nologger") + public static class ChildNoLoggerDeclared { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoLoggerDeclared.class, callLogger=SeedLogger.class)) + public static class HostSeedsLogger extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_childWithNoDeclarationReceivesSeed() { + MockRestClient.buildLax(HostSeedsLogger.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsLogger.class); + var childCtx = hostCtx.getRestChildren().asMap().get("nologger"); + assertNotNull(childCtx); + assertInstanceOf(SeedLogger.class, childCtx.getCallLogger(), + "Child with no callLogger declaration must receive the host's seeded SeedLogger"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: child's own explicit callLogger declaration wins over the host's seed. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/ownlogger", callLogger=ChildLogger.class) + public static class ChildWithOwnLogger { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnLogger.class, callLogger=SeedLogger.class)) + public static class HostSeedsLoggerButChildWins extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_childsOwnDeclarationWins() { + MockRestClient.buildLax(HostSeedsLoggerButChildWins.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsLoggerButChildWins.class); + var childCtx = hostCtx.getRestChildren().asMap().get("ownlogger"); + assertNotNull(childCtx); + assertInstanceOf(ChildLogger.class, childCtx.getCallLogger(), + "Child's own explicit callLogger must win over the host's seeded SeedLogger"); + } + + //------------------------------------------------------------------------------------------------------------ + // a03: isolation preserved -- host's OWN (non-seeded) @Rest(guards=...) must not leak to the child, even + // though the child receives a callLogger seed. If guards leaked, the child endpoint would 403. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/isocheck") + public static class ChildForIsolation { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(guards=BlockAllGuard.class, childrenDefs=@Child(type=ChildForIsolation.class, callLogger=SeedLogger.class)) + public static class HostWithGuardsAndSeed extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a03_isolationPreserved() throws Exception { + var c = MockRestClient.buildLax(HostWithGuardsAndSeed.class); + // Host's own (non-seeded) guards must not leak into the child's isolated context. + c.get("/isocheck/me").accept("text/plain").run().assertStatus(200).assertContent("me"); + var hostCtx = RestContext.getGlobalRegistry().get(HostWithGuardsAndSeed.class); + var childCtx = hostCtx.getRestChildren().asMap().get("isocheck"); + assertInstanceOf(SeedLogger.class, childCtx.getCallLogger(), + "The seeded callLogger must still apply alongside isolation from the host's own guards"); + } + + //------------------------------------------------------------------------------------------------------------ + // a04: the seed is not masked by the synthesized DefaultConfig fallback -- DefaultConfig sets + // maxInput="1000000"; a host-seeded @Child(maxInput=...) must still win (seed ranks above DefaultConfig). + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/maxcheck") + public static class ChildForMaxInput { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildForMaxInput.class, maxInput="12345")) + public static class HostSeedsMaxInput extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a04_seedNotMaskedByDefaultConfigFallback() { + MockRestClient.buildLax(HostSeedsMaxInput.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsMaxInput.class); + var childCtx = hostCtx.getRestChildren().asMap().get("maxcheck"); + assertNotNull(childCtx); + var opCtx = childCtx.getRestOperations().getOpContexts().get(0); + assertEquals(12345L, opCtx.getMaxInput(), + "Host-seeded maxInput must win over the framework's DefaultConfig fallback (1000000)"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildDefs_Discovery_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildDefs_Discovery_Test.java new file mode 100644 index 0000000000..f960dd77bb --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildDefs_Discovery_Test.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Discovery-order and dedup tests for {@link Rest#childrenDefs() @Rest(childrenDefs=@Child(...))}, mirroring + * {@code MixinDefs_Overrides_Test} sections F/I/M for the {@code mixins}/{@code mixinDefs} discovery rules. + */ +class ChildDefs_Discovery_Test extends TestBase { + + @Rest(path="/plain") + public static class PlainChild { + @RestGet("/ping") public String ping() { return "pong"; } + } + + @Rest(path="/plain2") + public static class PlainChild2 { + @RestGet("/ping") public String ping() { return "pong2"; } + } + + // A class named in both children= and childrenDefs= must be mounted exactly once. + @Rest(children=PlainChild.class, childrenDefs=@Child(type=PlainChild.class)) + public static class DedupHost extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_dedupedToFirstOccurrence() throws Exception { + var c = MockRestClient.buildLax(DedupHost.class); + c.get("/plain/ping").accept("text/plain").run().assertStatus(200).assertContent("pong"); + var hostCtx = RestContext.getGlobalRegistry().get(DedupHost.class); + assertEquals(1, hostCtx.getRestChildren().asMap().size()); + } + + // Bare children= entries are discovered before childrenDefs= entries. + @Rest(children=PlainChild.class, childrenDefs=@Child(type=PlainChild2.class)) + public static class OrderHost extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_bareDiscoveredBeforeChildrenDefs() { + MockRestClient.buildLax(OrderHost.class); + var hostCtx = RestContext.getGlobalRegistry().get(OrderHost.class); + var keys = new ArrayList<>(hostCtx.getRestChildren().asMap().keySet()); + assertEquals(List.of("plain", "plain2"), keys); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildDefs_Equivalence_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildDefs_Equivalence_Test.java new file mode 100644 index 0000000000..2f950cdbb6 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildDefs_Equivalence_Test.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Feature test for the {@link Rest#childrenDefs() @Rest(childrenDefs=@Child(...))} bare-equivalence rule. + * + * <p> + * Verifies the Phase 1 motivating capability — {@code @Child(type=X)} with zero overrides routes + * identically to a bare {@code @Rest(children=X)} entry — mirroring + * {@code MixinDefs_Overrides_Test.f01_bareEqualsEmptyDef}. + */ +class ChildDefs_Equivalence_Test extends TestBase { + + @Rest(path="/plain") + public static class PlainChild { + @RestGet("/ping") public String ping() { return "pong"; } + } + + @Rest(children=PlainChild.class) + public static class Bare extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Rest(childrenDefs=@Child(type=PlainChild.class)) + public static class EmptyDef extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_bareEqualsEmptyChildDef() throws Exception { + var bare = MockRestClient.buildLax(Bare.class); + var def = MockRestClient.buildLax(EmptyDef.class); + bare.get("/plain/ping").accept("text/plain").run().assertStatus(200).assertContent("pong"); + def.get("/plain/ping").accept("text/plain").run().assertStatus(200).assertContent("pong"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Converters_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Converters_Test.java new file mode 100644 index 0000000000..831e0006e8 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Converters_Test.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import org.apache.juneau.*; +import org.apache.juneau.http.response.*; +import org.apache.juneau.marshall.serializer.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.converter.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Phase 3 — proves the additive-security PREPEND shape for {@code @Child(converters=...)}: the + * host-seeded converter applies when the child declares none of its own, and runs BEFORE the child's own + * declared converter when both are present (prepend order, verified via string-append ordering). + */ +class ChildInheritance_Converters_Test extends TestBase { + + public static class SeedConv implements RestConverter { + @Override public Object convert(RestRequest req, Object res) throws BasicHttpException, SerializeException { + return res + "+seed"; + } + } + + public static class ChildConv implements RestConverter { + @Override public Object convert(RestRequest req, Object res) throws BasicHttpException, SerializeException { + return res + "+child"; + } + } + + //------------------------------------------------------------------------------------------------------------ + // a01: child with no converters of its own -> the seeded converter applies. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/noconv") + public static class ChildNoConv { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoConv.class, converters=SeedConv.class)) + public static class HostSeedsConvOnly extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_seededConverterAppliesWhenChildHasNone() throws Exception { + var c = MockRestClient.buildLax(HostSeedsConvOnly.class); + c.get("/noconv/me").accept("text/plain").run().assertStatus(200).assertContent("me+seed"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: child declares its own converter too -> BOTH apply, seed's converter runs first (prepend order). + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/bothconv", converters=ChildConv.class) + public static class ChildWithOwnConv { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnConv.class, converters=SeedConv.class)) + public static class HostSeedsConvOrder extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_seedRunsBeforeChildsOwnConverter() throws Exception { + var c = MockRestClient.buildLax(HostSeedsConvOrder.class); + c.get("/bothconv/me").accept("text/plain").run().assertStatus(200).assertContent("me+seed+child"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Debug_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Debug_Test.java new file mode 100644 index 0000000000..94dd2ac311 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Debug_Test.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.logging.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.MockServletRequest; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.debug.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Phase 4 — proves that {@code @Child(debug=...)} seeds each of {@code @Debug}'s three sub-fields + * (value/format/level) INDEPENDENTLY (per {@code RestContext.debugConfig}), not as one atomic all-or-nothing + * scalar: a child declaring only one sub-field still receives the seed's value for the others. + */ +class ChildInheritance_Debug_Test extends TestBase { + + public static class SeedFormat implements DebugFormat { + @Override public String format(DebugFormatContext context) { return "seed-format"; } + } + + public static class ChildFormat implements DebugFormat { + @Override public String format(DebugFormatContext context) { return "child-format"; } + } + + //------------------------------------------------------------------------------------------------------------ + // a01: host seeds value="always" + format=SeedFormat; child declares ONLY its own level="FINE". + // Expected: child's own level wins (independent sub-field); the seed's value/format still apply because + // the child left them at their sentinel. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/subfields", debug=@Debug(level="FINE")) + public static class ChildWithOnlyLevel { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOnlyLevel.class, debug=@Debug(value="always", format=SeedFormat.class))) + public static class HostSeedsValueAndFormat extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_subFieldsIndependentlySeeded() { + MockRestClient.buildLax(HostSeedsValueAndFormat.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsValueAndFormat.class); + var childCtx = hostCtx.getRestChildren().asMap().get("subfields"); + assertNotNull(childCtx); + + var debugConfig = childCtx.getBeanStore().getBean(DebugConfig.class).orElse(null); + assertNotNull(debugConfig, "Child context must expose a resolved DebugConfig bean"); + + var result = debugConfig.resolve(childCtx, MockServletRequest.create()); + assertTrue(result.enabled(), "Seed's value=\"always\" must enable debug even though the child never declared its own value"); + assertInstanceOf(SeedFormat.class, result.format(), "Seed's format must apply since the child left format at its sentinel"); + assertEquals(Level.FINE, result.level(), "Child's own explicit level must win over any (absent) seed level"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: child declares its own value="never" (opting out); the seed's format still applies because the child + // left format at its sentinel -- proving independence runs both ways. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/subfields2", debug=@Debug(value="never")) + public static class ChildWithOnlyValue { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOnlyValue.class, debug=@Debug(value="always", format=SeedFormat.class))) + public static class HostSeedsValueButChildOptsOut extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_childsOwnSubFieldWinsOthersInherited() { + MockRestClient.buildLax(HostSeedsValueButChildOptsOut.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsValueButChildOptsOut.class); + var childCtx = hostCtx.getRestChildren().asMap().get("subfields2"); + assertNotNull(childCtx); + + var debugConfig = childCtx.getBeanStore().getBean(DebugConfig.class).orElse(null); + assertNotNull(debugConfig); + + var result = debugConfig.resolve(childCtx, MockServletRequest.create()); + assertFalse(result.enabled(), "Child's own explicit value=\"never\" must win over the seed's value=\"always\""); + assertInstanceOf(SeedFormat.class, result.format(), "Seed's format must still apply -- the child left format at its sentinel"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_DefaultConfigChild_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_DefaultConfigChild_Test.java new file mode 100644 index 0000000000..447a265e98 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_DefaultConfigChild_Test.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.servlet.*; +import org.apache.juneau.utest.utils.*; +import org.junit.jupiter.api.*; + +/** + * Phase 4 hardening — regression test for an edge case not called out explicitly by name in spec §6.6: + * a CHILD CLASS that itself transitively implements {@code DefaultConfig} (e.g. by extending + * {@code BasicRestServlet}/{@code BasicRestServletGroup}, which implement {@code BasicUniversalConfig extends + * DefaultConfig}), rather than being a plain POJO with no framework-default mixin. + * + * <p> + * <b>Bug found and fixed by this test (Phase 4 hardening):</b> {@code computeRawRestAnnotations()}'s + * {@code hasDefaultConfig}-true branch originally appended the seed annotation after the ENTIRE {@code raw} + * chain unconditionally. For a plain child class (the common case exercised by every other Phase 2-4 test), + * {@code raw} never embeds {@code DefaultConfig} itself, so appending after {@code raw} correctly placed the + * seed above the framework's separately-synthesized {@code DefaultConfig} fallback. But when the child class + * itself transitively implements {@code DefaultConfig}, that interface's own {@code @Rest} entry is already + * PART OF {@code raw} (typically at its most-ancestor/least-derived slot) — appending the seed after the + * <i>entire</i> {@code raw} list placed the seed BELOW that embedded {@code DefaultConfig} entry, so + * {@code DefaultConfig}'s own concrete defaults (e.g. {@code partSerializer=OpenApiSerializer.class}) masked + * the host's seed entirely, violating spec §6.6's invariant that the seed ranks above the {@code + * DefaultConfig} fallback. Fixed by splitting {@code raw} into "the child's own real declarations" and "the + * (embedded) DefaultConfig entries" and inserting the seed between them, mirroring exactly how the + * separately-synthesized case already worked. + */ +class ChildInheritance_DefaultConfigChild_Test extends TestBase { + + public static class SeedPS extends FakeWriterSerializer { + public SeedPS(FakeWriterSerializer.Builder b) { super(b); } + } + + //------------------------------------------------------------------------------------------------------------ + // a01: child class extends BasicRestServlet (transitively implements DefaultConfig) and declares no + // partSerializer of its own -> the host's seed must still win over DefaultConfig's own baked-in + // partSerializer=OpenApiSerializer.class default, not be masked by it. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/dcchild") + public static class ChildImplementingDefaultConfig extends BasicRestServlet { + private static final long serialVersionUID = 1L; + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildImplementingDefaultConfig.class, partSerializer=SeedPS.class)) + public static class HostSeedsIntoDefaultConfigChild extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_seedNotMaskedByChildsOwnEmbeddedDefaultConfig() { + MockRestClient.buildLax(HostSeedsIntoDefaultConfigChild.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsIntoDefaultConfigChild.class); + var childCtx = hostCtx.getRestChildren().asMap().get("dcchild"); + assertNotNull(childCtx); + assertEquals(SeedPS.class, childCtx.getPartSerializer().getClass(), + "Host-seeded partSerializer must win over DefaultConfig's own baked-in OpenApiSerializer default, " + + "even when the child class transitively implements DefaultConfig itself"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: same shape, but the child ALSO declares its own explicit partSerializer -> the child's own + // declaration must still win over both the seed AND DefaultConfig (three-way precedence check). + //------------------------------------------------------------------------------------------------------------ + + public static class ChildPS extends FakeWriterSerializer { + public ChildPS(FakeWriterSerializer.Builder b) { super(b); } + } + + @Rest(path="/dcchild2", partSerializer=ChildPS.class) + public static class ChildImplementingDefaultConfigWithOwnPS extends BasicRestServlet { + private static final long serialVersionUID = 1L; + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildImplementingDefaultConfigWithOwnPS.class, partSerializer=SeedPS.class)) + public static class HostSeedsIntoDefaultConfigChildWithOwnPS extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_childsOwnDeclarationStillWinsOverSeedAndDefaultConfig() { + MockRestClient.buildLax(HostSeedsIntoDefaultConfigChildWithOwnPS.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsIntoDefaultConfigChildWithOwnPS.class); + var childCtx = hostCtx.getRestChildren().asMap().get("dcchild2"); + assertNotNull(childCtx); + assertEquals(ChildPS.class, childCtx.getPartSerializer().getClass(), + "Child's own explicit partSerializer must win over both the host's seed and DefaultConfig's default"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Guards_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Guards_Test.java new file mode 100644 index 0000000000..0f6924bc2e --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Guards_Test.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import java.util.concurrent.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.guard.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Phase 3 — proves the additive-security PREPEND shape for {@code @Child(guards=...)}: the host-seeded + * guard applies when the child declares none of its own (degrades to a plain "set"), the seeded guard runs + * outermost (first) when the child also declares its own guard, and the child's own guard is never dropped. + */ +class ChildInheritance_Guards_Test extends TestBase { + + static final List<String> ORDER = new CopyOnWriteArrayList<>(); + private static final List<String> EXPECTED_ORDER = List.of("seed", "child"); + + public static class SeedGuard extends RestGuard { + @Override public boolean isRequestAllowed(RestRequest req) { ORDER.add("seed"); return true; } + } + + public static class ChildGuard extends RestGuard { + @Override public boolean isRequestAllowed(RestRequest req) { ORDER.add("child"); return true; } + } + + public static class BlockingSeedGuard extends RestGuard { + @Override public boolean isRequestAllowed(RestRequest req) { + return "yes".equals(req.getHeaderParam("X-Seed-Allowed").orElse(null)); + } + } + + public static class AllowOnlyChildHeader extends RestGuard { + @Override public boolean isRequestAllowed(RestRequest req) { + return "yes".equals(req.getHeaderParam("X-Child-Allowed").orElse(null)); + } + } + + //------------------------------------------------------------------------------------------------------------ + // a01: child with no guards of its own -> the seeded guard applies (degrades to a plain "set"). + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/noguard") + public static class ChildNoGuard { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoGuard.class, guards=BlockingSeedGuard.class)) + public static class HostSeedsGuardOnly extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_seededGuardAppliesWhenChildHasNone() throws Exception { + var c = MockRestClient.buildLax(HostSeedsGuardOnly.class); + c.get("/noguard/me").run().assertStatus(403); + c.get("/noguard/me").header("X-Seed-Allowed", "yes").accept("text/plain").run().assertStatus(200).assertContent("me"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: child declares its own guard too -> BOTH apply, and the seed's guard runs first (outermost). + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/bothguards", guards=ChildGuard.class) + public static class ChildWithOwnGuard { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnGuard.class, guards=SeedGuard.class)) + public static class HostSeedsGuardOrder extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_bothApplySeedRunsFirst() throws Exception { + ORDER.clear(); + var c = MockRestClient.buildLax(HostSeedsGuardOrder.class); + c.get("/bothguards/me").accept("text/plain").run().assertStatus(200).assertContent("me"); + assertEquals(EXPECTED_ORDER, ORDER, + "Host-seeded guard must run outermost (first), before the child's own declared guard"); + } + + //------------------------------------------------------------------------------------------------------------ + // a03: the child's own guard is never dropped -- the seed's guard alone would allow the request, but the + // child's own (stricter) guard must still gate it. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/dropcheck", guards=AllowOnlyChildHeader.class) + public static class ChildWithOwnGuard2 { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnGuard2.class, guards=SeedGuard.class)) + public static class HostSeedsGuardKeepsChildGuard extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a03_childsOwnGuardNeverDropped() throws Exception { + var c = MockRestClient.buildLax(HostSeedsGuardKeepsChildGuard.class); + // SeedGuard always allows, but the child's own AllowOnlyChildHeader guard must still gate the request. + c.get("/dropcheck/me").run().assertStatus(403); + c.get("/dropcheck/me").header("X-Child-Allowed", "yes").accept("text/plain").run().assertStatus(200).assertContent("me"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_NoInherit_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_NoInherit_Test.java new file mode 100644 index 0000000000..3a5e42d6c5 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_NoInherit_Test.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.guard.*; +import org.apache.juneau.rest.server.logger.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Phase 5 — proves that a child's own {@code @Rest(noInherit="<property>")} cuts the corresponding + * {@code @Child} seed too, per spec §4's "{@code noInherit} interaction" subsection: because the seed is + * injected as an ordinary least-derived entry in the child's own {@code getRestAnnotations()} chain + * (§6.6), the existing generic {@code noInherit} cutoff scan in {@code getRestAnnotationsForProperty} + * naturally truncates it too, with no seed-aware special-casing required. Verified uniformly across BOTH + * seedable-property buckets: an additive-security member ({@code guards}) and a child-wins scalar + * ({@code callLogger}). + */ +class ChildInheritance_NoInherit_Test extends TestBase { + + public static class BlockAllGuard extends RestGuard { + @Override public boolean isRequestAllowed(RestRequest req) { return false; } + } + + public static class SeedLogger extends CallLogger { + public SeedLogger(org.apache.juneau.commons.inject.BeanStore bs) { super(bs); } + } + + //------------------------------------------------------------------------------------------------------------ + // a01: additive-security member (guards) -- child declares noInherit="guards" -> receives NONE of the + // host's seeded guards (the endpoint must be reachable WITHOUT satisfying the seeded guard at all, not + // merely "the child's own guards are present"). + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/noinheritguards", noInherit="guards") + public static class ChildNoInheritGuards { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoInheritGuards.class, guards=BlockAllGuard.class)) + public static class HostSeedsGuardButChildOptsOut extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_noInheritCutsSeededGuards() throws Exception { + var c = MockRestClient.buildLax(HostSeedsGuardButChildOptsOut.class); + // If the seeded BlockAllGuard were still applied, this request would always 403. noInherit="guards" + // must cut it entirely, leaving the child with zero guards. + c.get("/noinheritguards/me").accept("text/plain").run().assertStatus(200).assertContent("me"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: child-wins scalar (callLogger) -- child declares noInherit="callLogger" -> receives NONE of the + // host's seeded callLogger (falls back to the framework default, not the seed). + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/noinheritlogger", noInherit="callLogger") + public static class ChildNoInheritLogger { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoInheritLogger.class, callLogger=SeedLogger.class)) + public static class HostSeedsLoggerButChildOptsOut extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_noInheritCutsSeededCallLogger() { + MockRestClient.buildLax(HostSeedsLoggerButChildOptsOut.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsLoggerButChildOptsOut.class); + var childCtx = hostCtx.getRestChildren().asMap().get("noinheritlogger"); + assertNotNull(childCtx); + assertFalse(childCtx.getCallLogger() instanceof SeedLogger, + "Child's own noInherit=\"callLogger\" must cut the host's seeded SeedLogger entirely, " + + "falling back to the framework default rather than the seed"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_PartParser_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_PartParser_Test.java new file mode 100644 index 0000000000..28f9addd49 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_PartParser_Test.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.servlet.*; +import org.apache.juneau.utest.utils.*; +import org.junit.jupiter.api.*; + +/** + * Phase 4 — proves the CHILD-WINS scalar shape for {@code @Child(partParser=...)}: the host-seeded + * part parser applies when the child declares none of its own, but the child's own explicit + * {@code @Rest(partParser=...)} wins over the seed when present. + */ +class ChildInheritance_PartParser_Test extends TestBase { + + public static class SeedPP extends FakeReaderParser { + public SeedPP(FakeReaderParser.Builder b) { super(b); } + } + + public static class ChildPP extends FakeReaderParser { + public ChildPP(FakeReaderParser.Builder b) { super(b); } + } + + //------------------------------------------------------------------------------------------------------------ + // a01: child declares no partParser -> host's seed takes effect. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/nopp") + public static class ChildNoPPDeclared { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoPPDeclared.class, partParser=SeedPP.class)) + public static class HostSeedsPP extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_childWithNoDeclarationReceivesSeed() { + MockRestClient.buildLax(HostSeedsPP.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsPP.class); + var childCtx = hostCtx.getRestChildren().asMap().get("nopp"); + assertNotNull(childCtx); + assertEquals(SeedPP.class, childCtx.getPartParser().getClass(), + "Child with no partParser declaration must receive the host's seeded SeedPP"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: child's own explicit partParser wins over the host's seed. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/ownpp", partParser=ChildPP.class) + public static class ChildWithOwnPP { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnPP.class, partParser=SeedPP.class)) + public static class HostSeedsPPButChildWins extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_childsOwnDeclarationWins() { + MockRestClient.buildLax(HostSeedsPPButChildWins.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsPPButChildWins.class); + var childCtx = hostCtx.getRestChildren().asMap().get("ownpp"); + assertNotNull(childCtx); + assertEquals(ChildPP.class, childCtx.getPartParser().getClass(), + "Child's own explicit partParser must win over the host's seeded SeedPP"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_PartSerializer_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_PartSerializer_Test.java new file mode 100644 index 0000000000..4a01122046 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_PartSerializer_Test.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.servlet.*; +import org.apache.juneau.utest.utils.*; +import org.junit.jupiter.api.*; + +/** + * Phase 4 — proves the CHILD-WINS scalar shape for {@code @Child(partSerializer=...)}: the host-seeded + * part serializer applies when the child declares none of its own, but the child's own explicit + * {@code @Rest(partSerializer=...)} wins over the seed when present. + */ +class ChildInheritance_PartSerializer_Test extends TestBase { + + public static class SeedPS extends FakeWriterSerializer { + public SeedPS(FakeWriterSerializer.Builder b) { super(b); } + } + + public static class ChildPS extends FakeWriterSerializer { + public ChildPS(FakeWriterSerializer.Builder b) { super(b); } + } + + //------------------------------------------------------------------------------------------------------------ + // a01: child declares no partSerializer -> host's seed takes effect. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/nops") + public static class ChildNoPSDeclared { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoPSDeclared.class, partSerializer=SeedPS.class)) + public static class HostSeedsPS extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_childWithNoDeclarationReceivesSeed() { + MockRestClient.buildLax(HostSeedsPS.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsPS.class); + var childCtx = hostCtx.getRestChildren().asMap().get("nops"); + assertNotNull(childCtx); + assertEquals(SeedPS.class, childCtx.getPartSerializer().getClass(), + "Child with no partSerializer declaration must receive the host's seeded SeedPS"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: child's own explicit partSerializer wins over the host's seed. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/ownps", partSerializer=ChildPS.class) + public static class ChildWithOwnPS { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnPS.class, partSerializer=SeedPS.class)) + public static class HostSeedsPSButChildWins extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_childsOwnDeclarationWins() { + MockRestClient.buildLax(HostSeedsPSButChildWins.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsPSButChildWins.class); + var childCtx = hostCtx.getRestChildren().asMap().get("ownps"); + assertNotNull(childCtx); + assertEquals(ChildPS.class, childCtx.getPartSerializer().getClass(), + "Child's own explicit partSerializer must win over the host's seeded SeedPS"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_RoleGuard_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_RoleGuard_Test.java new file mode 100644 index 0000000000..25b93ac137 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_RoleGuard_Test.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Phase 3 — proves the additive-security AND-STACK shape for {@code @Child(roleGuard=...)}: a + * host-seeded {@code roleGuard} and the child's own explicit {@code roleGuard} both apply as independent, + * ANDed {@code RoleBasedRestGuard} instances — the child can add restriction but never remove or + * weaken the host's. + */ +class ChildInheritance_RoleGuard_Test extends TestBase { + + //------------------------------------------------------------------------------------------------------------ + // a01: host seeds roleGuard="admin"; child declares its own roleGuard="special" -> BOTH must be satisfied. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/rg", roleGuard="special") + public static class ChildWithOwnRoleGuard { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnRoleGuard.class, roleGuard="admin")) + public static class HostSeedsRoleGuard extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_bothRoleGuardsMustBeSatisfied() throws Exception { + var c = MockRestClient.buildLax(HostSeedsRoleGuard.class); + c.get("/rg/me").roles("admin").run().assertStatus(403); // missing "special" + c.get("/rg/me").roles("special").run().assertStatus(403); // missing "admin" + c.get("/rg/me").roles("admin", "special").accept("text/plain").run().assertStatus(200).assertContent("me"); + } + + //------------------------------------------------------------------------------------------------------------ + // a02: child declares no roleGuard of its own -> only the host-seeded roleGuard applies. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/rg2") + public static class ChildNoRoleGuard { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoRoleGuard.class, roleGuard="admin")) + public static class HostSeedsRoleGuardOnly extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_seedOnlyAppliesWhenChildDeclaresNone() throws Exception { + var c = MockRestClient.buildLax(HostSeedsRoleGuardOnly.class); + c.get("/rg2/me").run().assertStatus(403); + c.get("/rg2/me").roles("admin").accept("text/plain").run().assertStatus(200).assertContent("me"); + } + + //------------------------------------------------------------------------------------------------------------ + // a03: rolesDeclared accumulates across host seed AND child, into the single shared declared-role set used + // by every RoleBasedRestGuard in the chain (RestOpContext.guards()) -- the child's own rolesDeclared="admin" + // feeds the host-seeded PATTERN-based roleGuard="ad*", which has no declared-role names of its own. + //------------------------------------------------------------------------------------------------------------ + + @Rest(path="/rg3", rolesDeclared="admin") + public static class ChildDeclaresRolesOnly { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildDeclaresRolesOnly.class, roleGuard="ad*")) + public static class HostSeedsPatternRoleGuard extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a03_rolesDeclaredFromChildFeedsHostSeededPatternRoleGuard() throws Exception { + var c = MockRestClient.buildLax(HostSeedsPatternRoleGuard.class); + c.get("/rg3/me").run().assertStatus(403); + c.get("/rg3/me").roles("admin").accept("text/plain").run().assertStatus(200).assertContent("me"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Scalars_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Scalars_Test.java new file mode 100644 index 0000000000..53e8b27537 --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Scalars_Test.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.child; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Phase 4 — proves the CHILD-WINS scalar shape for {@code @Child(defaultCharset=...)} and + * {@code @Child(maxInput=...)}, both of which resolve through {@code RestContext.mergeReplacedStringAttribute} + * (last-non-sentinel-wins over the annotation chain), exactly like {@code callLogger}/{@code partSerializer}/ + * {@code partParser}. + * + * <p> + * {@code defaultAccept}/{@code defaultContentType} are intentionally NOT seed slots on {@code @Child} (see + * {@link Child} javadoc): they resolve through the {@code defaultRequestHeaders} memoizer's + * {@code HttpHeaderList.setDefault(...)} first-wins semantics rather than {@code mergeReplacedStringAttribute}, + * which would make the host's seed win over the child's own explicit declaration — the opposite of the + * child-wins contract. That's pre-existing framework behavior unrelated to {@code @Child} (a plain + * superclass/subclass {@code @Rest} hierarchy exhibits the same ancestor-wins-over-descendant behavior), so + * these two members are deferred rather than shipped as a contract violation. + */ +class ChildInheritance_Scalars_Test extends TestBase { + + //================================================================================================================== + // defaultCharset -- true child-wins (via mergeReplacedStringAttribute) + //================================================================================================================== + + @Rest(path="/nocharset") + public static class ChildNoCharsetDeclared { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildNoCharsetDeclared.class, defaultCharset="utf-16")) + public static class HostSeedsCharset extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_charset_childWithNoDeclarationReceivesSeed() { + MockRestClient.buildLax(HostSeedsCharset.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsCharset.class); + var childCtx = hostCtx.getRestChildren().asMap().get("nocharset"); + assertNotNull(childCtx); + var opCtx = childCtx.getRestOperations().getOpContexts().get(0); + assertEquals(StandardCharsets.UTF_16, opCtx.getDefaultCharset(), + "Child with no defaultCharset declaration must receive the host's seeded utf-16"); + } + + @Rest(path="/owncharset", defaultCharset="utf-16") + public static class ChildWithOwnCharset { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnCharset.class, defaultCharset="iso-8859-1")) + public static class HostSeedsCharsetButChildWins extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a02_charset_childsOwnDeclarationWins() { + MockRestClient.buildLax(HostSeedsCharsetButChildWins.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsCharsetButChildWins.class); + var childCtx = hostCtx.getRestChildren().asMap().get("owncharset"); + assertNotNull(childCtx); + var opCtx = childCtx.getRestOperations().getOpContexts().get(0); + assertEquals(StandardCharsets.UTF_16, opCtx.getDefaultCharset(), + "Child's own explicit defaultCharset must win over the host's seeded iso-8859-1"); + } + + //================================================================================================================== + // maxInput -- true child-wins (via mergeReplacedStringAttribute); seed-applies-when-silent already covered by + // ChildContext_Seed_Test.a04 (seed vs. DefaultConfig fallback) -- this file adds the child-wins-when-explicit half. + //================================================================================================================== + + @Rest(path="/ownmax", maxInput="111") + public static class ChildWithOwnMaxInput { + @RestGet(path="/me") public String me() { return "me"; } + } + + @Rest(childrenDefs=@Child(type=ChildWithOwnMaxInput.class, maxInput="222")) + public static class HostSeedsMaxInputButChildWins extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a03_maxInput_childsOwnDeclarationWins() { + MockRestClient.buildLax(HostSeedsMaxInputButChildWins.class); + var hostCtx = RestContext.getGlobalRegistry().get(HostSeedsMaxInputButChildWins.class); + var childCtx = hostCtx.getRestChildren().asMap().get("ownmax"); + assertNotNull(childCtx); + var opCtx = childCtx.getRestOperations().getOpContexts().get(0); + assertEquals(111L, opCtx.getMaxInput(), + "Child's own explicit maxInput must win over the host's seeded 222"); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/ChildAnnotation_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/ChildAnnotation_Test.java new file mode 100644 index 0000000000..5fd543d0ce --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/ChildAnnotation_Test.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.server; + +import static org.apache.juneau.BasicTestUtils.*; +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.apache.juneau.marshall.uon.*; +import org.apache.juneau.rest.server.auth.*; +import org.apache.juneau.rest.server.converter.*; +import org.apache.juneau.rest.server.logger.*; +import org.junit.jupiter.api.*; + +/** + * Tests for the {@link ChildAnnotation} companion (programmatic builder + synthetic impl) and equivalency with + * the declarative {@link Child @Child} annotation form. + */ +@SuppressWarnings({ + "unchecked" // Generic Class<? extends X>[] varargs in the builder slot setters are populated with single literals in tests. +}) +class ChildAnnotation_Test extends TestBase { + + public static class FooChild {} + + private static ChildAnnotation.Builder fullBuilder() { + return ChildAnnotation.create() + .type(FooChild.class) + .guards(BearerTokenGuard.class) + .roleGuard("admin") + .rolesDeclared("admin,user") + .converters(Traversable.class) + .callLogger(BasicCallLogger.class) + .partSerializer(UonSerializer.class) + .partParser(UonParser.class) + .debug(null) // null coalesces to DebugAnnotation.DEFAULT + .defaultCharset("utf-8") + .maxInput("1M"); + } + + Child a1 = fullBuilder().build(); + Child a2 = fullBuilder().build(); + + @Test void a01_stringAccessors() { + assertEquals(FooChild.class, a1.type()); + assertEquals("admin", a1.roleGuard()); + assertEquals("admin,user", a1.rolesDeclared()); + assertEquals("utf-8", a1.defaultCharset()); + assertEquals("1M", a1.maxInput()); + } + + @Test void a01b_classAndAnnotationAccessors() { + assertEquals(BearerTokenGuard.class, a1.guards()[0]); + assertEquals(Traversable.class, a1.converters()[0]); + assertEquals(BasicCallLogger.class, a1.callLogger()); + assertEquals(UonSerializer.class, a1.partSerializer()); + assertEquals(UonParser.class, a1.partParser()); + // debug(null) coalesced to the default @Debug. + assertEquals(DebugAnnotation.DEFAULT, a1.debug()); + } + + @Test void a02_testEquivalency() { + assertEquals(a2, a1); + assertNotEqualsAny(a1.hashCode(), 0, -1); + assertEquals(a1.hashCode(), a2.hashCode()); + } + + @Test void a03_defaultInstance() { + var d = ChildAnnotation.DEFAULT; + assertEquals("", d.roleGuard()); + assertEquals(0, d.guards().length); + } + + @Test void a04_debugNonNullValueKept() { + // The non-null branch of debug(Debug): a supplied @Debug is kept as-is (not coalesced to DEFAULT). + var dbg = DebugAnnotation.create().value("always").build(); + var m = ChildAnnotation.create().type(FooChild.class).debug(dbg).build(); + assertEquals(dbg, m.debug()); + assertEquals("always", m.debug().value()); + } + + // Comparison with the declarative @Child form. + + @Rest(childrenDefs=@Child( + type=FooChild.class, + guards=BearerTokenGuard.class, + roleGuard="admin", + rolesDeclared="admin,user", + converters=Traversable.class, + callLogger=BasicCallLogger.class, + partSerializer=UonSerializer.class, + partParser=UonParser.class, + defaultCharset="utf-8", + maxInput="1M" + )) + public static class D1 {} + + @Test void d01_comparisonWithDeclarativeAnnotation() { + // The builder-produced a1 must be equal+hashCode-equal to the declarative @Child form (same slots). + var d1 = D1.class.getAnnotationsByType(Rest.class)[0].childrenDefs()[0]; + assertEquals(a1, d1); + assertEquals(a1.hashCode(), d1.hashCode()); + } +} diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/LazyChildren_ChildDefs_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/LazyChildren_ChildDefs_Test.java new file mode 100644 index 0000000000..e06545d2ca --- /dev/null +++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/LazyChildren_ChildDefs_Test.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.server; + +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.mock.classic.*; +import org.apache.juneau.rest.server.logger.*; +import org.apache.juneau.rest.server.servlet.*; +import org.junit.jupiter.api.*; + +/** + * Phase 6 — proves a {@code @Rest(lazyChildren="true", childrenDefs=@Child(...))} child receives its + * host-seeded settings on first-request materialization, not just when eagerly built. Mirrors + * {@link LazyChildren_Test}'s style/package (same package so it lives alongside the base lazy-children suite). + */ +class LazyChildren_ChildDefs_Test extends TestBase { + + public static class SeedLogger extends CallLogger { + public SeedLogger(org.apache.juneau.commons.inject.BeanStore bs) { super(bs); } + } + + @Rest(path="/lazychild") + public static class LazyChildResource { + @RestGet("/ping") public String ping() { return "lazy-pong"; } + } + + @Rest( + path="/root", + lazyChildren="true", + childrenDefs=@Child(type=LazyChildResource.class, callLogger=SeedLogger.class) + ) + public static class LazySeedParent extends BasicRestServletGroup { + private static final long serialVersionUID = 1L; + } + + @Test void a01_seedAppliesAfterLazyMaterialization() throws Exception { + var parent = new LazySeedParent(); + var client = MockRestClient.createLax(parent).build(); + var rc = parent.getContext(); + + assertTrue(rc.isLazyChildren()); + + // Before first request: registered for routing but not yet materialized. + var entries = rc.getRestChildren().getLazyEntries(); + var entry = entries.get("lazychild"); + assertNotNull(entry, "Lazy entry key should be 'lazychild'"); + assertFalse(entry.isMaterialized(), "Should not be materialized before first request"); + + // First request triggers materialization. + client.get("/lazychild/ping").run().assertStatus(200).assertContent("lazy-pong"); + assertTrue(entry.isMaterialized(), "Should be materialized after first request"); + + // The materialized context must carry the host's seeded callLogger -- proving the ResolvedChild + // (including its @Child seed) survived deferred construction, not just eager construction. + // (Materialized lazy children live on the LazyChildEntry itself, not in RestChildren.asMap() -- + // that map holds only eagerly-built children.) + var childCtx = entry.materialized; + assertNotNull(childCtx); + assertInstanceOf(SeedLogger.class, childCtx.getCallLogger(), + "Lazily-materialized child must receive the host's seeded SeedLogger, same as an eager child would"); + } +} diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Child.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Child.java new file mode 100644 index 0000000000..1edce8670f --- /dev/null +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Child.java @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.server; + +import static java.lang.annotation.RetentionPolicy.*; + +import java.lang.annotation.*; + +import org.apache.juneau.marshall.httppart.*; +import org.apache.juneau.rest.server.converter.*; +import org.apache.juneau.rest.server.guard.*; +import org.apache.juneau.rest.server.logger.*; + +/** + * Host-side setting seed for the {@link Rest#childrenDefs() @Rest(childrenDefs=...)} attribute. + * + * <p> + * Declares a routed child class <b>and</b> lets the <i>host</i> seed a curated set of {@code @Rest}-level + * settings onto that child's otherwise-isolated {@link RestContext} — the child-resource analog of + * {@link Rest#mixinDefs() @Rest(mixinDefs=...)}. This is the host-side complement to + * {@link Rest#children() @Rest(children=...)} (which takes bare classes and offers no seed hook). + * + * <h5 class='section'>Example:</h5> + * <p class='bjava'> + * <ja>@Rest</ja>( + * childrenDefs=<ja>@Child</ja>(type=FooChild.<jk>class</jk>, callLogger=SeedLogger.<jk>class</jk>) + * ) + * <jk>public class</jk> MyResource { ... } + * </p> + * + * <h5 class='section'>Seed semantics</h5> + * <p> + * Unlike {@link Mixin @Mixin} overrides (which win over an <i>inherited</i> chain), children are + * <b>isolated</b> from the host's resolution chain by design, so a {@code @Child} seed doesn't override + * anything — it seeds settings onto an otherwise-isolated child context: + * <ul> + * <li><b>Additive-security</b> ({@code guards}, {@code converters}, {@code roleGuard}, {@code rolesDeclared}) + * — the host contributes, the child can't remove or weaken it (list-shaped members prepend; the two + * role-based members AND-stack alongside the child's own value). + * <li><b>Child-wins scalars</b> ({@code callLogger}, {@code partSerializer}, {@code partParser}, {@code debug}, + * {@code defaultCharset}, {@code maxInput}) — the seed is a default/fallback; the child's own explicit + * {@code @Rest} declaration wins when present. + * </ul> + * + * <p> + * A child's own {@code @Rest(noInherit="<property>")} cuts the corresponding {@code @Child} seed too, + * for both buckets above — the child always stays in full control of its own configuration. + * + * <p> + * There is no {@code noInherit()} member on {@code @Child} itself (nothing to cut in an isolated context), and + * no {@code path()}/{@code paths()} re-mount member (the child's own {@code @Rest(path)} stays authoritative). + * + * <h5 class='section'>See Also:</h5><ul> + * <li class='ja'>{@link Rest#childrenDefs()} + * <li class='ja'>{@link Rest#children()} + * </ul> + * + * @since 10.0.0 + */ +@Target({}) +@Retention(RUNTIME) +public @interface Child { + + /** + * The child class to route. + * + * <p> + * Required. Equivalent to a bare entry in {@link Rest#children()}, but with the seed slots below. + * + * @return The child class. + */ + Class<?> type(); + + //----------------------------------------------------------------------------------------------------------------- + // Additive-security seed slots — host contributes, child can't remove or weaken. + //----------------------------------------------------------------------------------------------------------------- + + /** + * Host-seeded {@link Rest#guards() guards} for this child's endpoints (prepended before the child's own). + * + * @return The annotation value. + */ + Class<? extends RestGuard>[] guards() default {}; + + /** + * Host-seeded {@link Rest#converters() converters} for this child's endpoints (prepended before the child's own). + * + * @return The annotation value. + */ + Class<? extends RestConverter>[] converters() default {}; + + /** + * Host-seeded {@link Rest#roleGuard() roleGuard} for this child's endpoints (AND-stacks with the child's own). + * + * @return The annotation value. + */ + String roleGuard() default ""; + + /** + * Host-seeded {@link Rest#rolesDeclared() rolesDeclared} for this child's endpoints (AND-stacks with the child's own). + * + * @return The annotation value. + */ + String rolesDeclared() default ""; + + //----------------------------------------------------------------------------------------------------------------- + // Child-wins scalar seed slots — seed is a default/fallback; the child's own explicit value wins. + //----------------------------------------------------------------------------------------------------------------- + + /** + * Host-seeded {@link Rest#callLogger() callLogger} default for this child's endpoints. + * + * @return The annotation value. + */ + Class<? extends CallLogger> callLogger() default CallLogger.Void.class; + + /** + * Host-seeded {@link Rest#partSerializer() partSerializer} default for this child's endpoints. + * + * @return The annotation value. + */ + Class<? extends HttpPartSerializer> partSerializer() default HttpPartSerializer.Void.class; + + /** + * Host-seeded {@link Rest#partParser() partParser} default for this child's endpoints. + * + * @return The annotation value. + */ + Class<? extends HttpPartParser> partParser() default HttpPartParser.Void.class; + + /** + * Host-seeded {@link Rest#debug() debug} default for this child's endpoints (each sub-field independently seeded). + * + * @return The annotation value. + */ + Debug debug() default @Debug; + + /** + * Host-seeded {@link Rest#defaultCharset() defaultCharset} default for this child's endpoints. + * + * @return The annotation value. + */ + String defaultCharset() default ""; + + /** + * Host-seeded {@link Rest#maxInput() maxInput} default for this child's endpoints. + * + * @return The annotation value. + */ + String maxInput() default ""; +} diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/ChildAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/ChildAnnotation.java new file mode 100644 index 0000000000..2c9cb127e3 --- /dev/null +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/ChildAnnotation.java @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.juneau.rest.server; + +import static org.apache.juneau.commons.utils.Shorts.*; + +import org.apache.juneau.commons.*; +import org.apache.juneau.marshall.httppart.*; +import org.apache.juneau.rest.server.converter.*; +import org.apache.juneau.rest.server.guard.*; +import org.apache.juneau.rest.server.logger.*; + +/** + * Utility classes and methods for the {@link Child @Child} annotation. + * + * <h5 class='section'>See Also:</h5><ul> + * <li class='ja'>{@link Child} + * <li class='ja'>{@link Rest#childrenDefs()} + * </ul> + * + * @since 10.0.0 + */ +public class ChildAnnotation { + + /** + * Prevents instantiation. + */ + private ChildAnnotation() {} + + /** + * Builder class. + */ + @SuppressWarnings({ + "unchecked" // Type erasure requires cast for generic class-array fields. + }) + public static class Builder extends AnnotationObject.Builder { + + Class<?> type = Object.class; + Class<? extends RestGuard>[] guards = new Class[0]; + Class<? extends RestConverter>[] converters = new Class[0]; + String roleGuard = ""; + String rolesDeclared = ""; + Class<? extends CallLogger> callLogger = CallLogger.Void.class; + Class<? extends HttpPartSerializer> partSerializer = HttpPartSerializer.Void.class; + Class<? extends HttpPartParser> partParser = HttpPartParser.Void.class; + Debug debug = DebugAnnotation.DEFAULT; + String defaultCharset = ""; + String maxInput = ""; + + /** + * Constructor. + */ + protected Builder() { + super(Child.class); + } + + /** + * Sets the {@link Child#type()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder type(Class<?> value) { type = value; return this; } + + /** + * Sets the {@link Child#guards()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder guards(Class<? extends RestGuard>... value) { guards = value; return this; } + + /** + * Sets the {@link Child#converters()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder converters(Class<? extends RestConverter>... value) { converters = value; return this; } + + /** + * Sets the {@link Child#roleGuard()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder roleGuard(String value) { roleGuard = value; return this; } + + /** + * Sets the {@link Child#rolesDeclared()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder rolesDeclared(String value) { rolesDeclared = value; return this; } + + /** + * Sets the {@link Child#callLogger()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder callLogger(Class<? extends CallLogger> value) { callLogger = value; return this; } + + /** + * Sets the {@link Child#partSerializer()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder partSerializer(Class<? extends HttpPartSerializer> value) { partSerializer = value; return this; } + + /** + * Sets the {@link Child#partParser()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder partParser(Class<? extends HttpPartParser> value) { partParser = value; return this; } + + /** + * Sets the {@link Child#debug()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder debug(Debug value) { debug = value == null ? DebugAnnotation.DEFAULT : value; return this; } + + /** + * Sets the {@link Child#defaultCharset()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder defaultCharset(String value) { defaultCharset = value; return this; } + + /** + * Sets the {@link Child#maxInput()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder maxInput(String value) { maxInput = value; return this; } + + /** + * Instantiates a new {@link Child @Child} object initialized with this builder. + * + * @return A new {@link Child @Child} object. + */ + public Child build() { + return new Impl(this); + } + } + + @SuppressWarnings({ + "java:S2160" // equals() inherited from AnnotationObject compares all annotation interface methods; subclass fields are accessed via those methods. + }) + private static class Impl extends AnnotationObject implements Child { + + private final Class<?> type; + private final Class<? extends RestGuard>[] guards; + private final Class<? extends RestConverter>[] converters; + private final String roleGuard; + private final String rolesDeclared; + private final Class<? extends CallLogger> callLogger; + private final Class<? extends HttpPartSerializer> partSerializer; + private final Class<? extends HttpPartParser> partParser; + private final Debug debug; + private final String defaultCharset; + private final String maxInput; + + Impl(ChildAnnotation.Builder b) { + super(b); + type = b.type; + guards = cp(b.guards); + converters = cp(b.converters); + roleGuard = b.roleGuard; + rolesDeclared = b.rolesDeclared; + callLogger = b.callLogger; + partSerializer = b.partSerializer; + partParser = b.partParser; + debug = b.debug; + defaultCharset = b.defaultCharset; + maxInput = b.maxInput; + } + + @Override /* Overridden from Child */ public Class<?> type() { return type; } + @Override /* Overridden from Child */ public Class<? extends RestGuard>[] guards() { return cp(guards); } + @Override /* Overridden from Child */ public Class<? extends RestConverter>[] converters() { return cp(converters); } + @Override /* Overridden from Child */ public String roleGuard() { return roleGuard; } + @Override /* Overridden from Child */ public String rolesDeclared() { return rolesDeclared; } + @Override /* Overridden from Child */ public Class<? extends CallLogger> callLogger() { return callLogger; } + @Override /* Overridden from Child */ public Class<? extends HttpPartSerializer> partSerializer() { return partSerializer; } + @Override /* Overridden from Child */ public Class<? extends HttpPartParser> partParser() { return partParser; } + @Override /* Overridden from Child */ public Debug debug() { return debug; } + @Override /* Overridden from Child */ public String defaultCharset() { return defaultCharset; } + @Override /* Overridden from Child */ public String maxInput() { return maxInput; } + } + + /** + * Builder creator. + * + * @return A new builder. + */ + public static Builder create() { + return new Builder(); + } + + /** Default {@link Child} instance (empty seed slots; {@code type=Object.class}). */ + public static final Child DEFAULT = create().build(); +} diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java index 17c316bd53..266fe2bb51 100644 --- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java @@ -223,6 +223,47 @@ public @interface Rest { */ Class<?>[] children() default {}; + /** + * Rich child definitions — the host-side seed form of {@link #children()}. + * + * <p> + * Each {@link Child @Child} entry names a child class (via {@link Child#type()}) <b>and</b> lets the host + * seed a curated set of {@code @Rest}-level settings onto that child's otherwise-isolated + * {@link RestContext} — guards, role guards, a call logger, part serializer/parser, debug, and a + * handful of default-scalar settings — without editing the child class. + * + * <p> + * Unlike {@link #mixinDefs() mixinDefs}, this does not override an inherited chain (children never inherit + * from the host); it seeds settings onto an isolated context. Additive-security seed members ({@code guards}, + * {@code converters}, {@code roleGuard}, {@code rolesDeclared}) can't be removed or weakened by the child; + * the remaining scalar seed members are a fallback the child's own explicit {@code @Rest} declaration wins + * over. See {@link Child} for the full semantics. + * + * <h5 class='section'>Example:</h5> + * <p class='bjava'> + * <ja>@Rest</ja>( + * childrenDefs=<ja>@Child</ja>(type=FooChild.<jk>class</jk>, callLogger=SeedLogger.<jk>class</jk>) + * ) + * <jk>public class</jk> MyResource { ... } + * </p> + * + * <p> + * This attribute is additive to {@link #children()}: bare-class entries in {@code children()} and rich + * entries here are discovered together (bare classes first, then {@code childrenDefs}). A + * {@code @Child(type=X.class)} with no seed members is the exact equivalent of a bare + * {@code children=X.class} entry. + * + * <h5 class='section'>See Also:</h5><ul> + * <li class='ja'>{@link Child} + * <li class='jm'>{@link #children()} + * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ChildResources#host-side-seeding-with-child-1000">Child Resources — Host-side seeding with @Child</a> + * </ul> + * + * @return The annotation value. + * @since 10.0.0 + */ + Child[] childrenDefs() default {}; + /** * REST mixins. * diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java index 26b241119c..b654d6f7c1 100644 --- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java @@ -77,6 +77,7 @@ public class RestAnnotation { private Debug debug = DebugAnnotation.DEFAULT; private Class<? extends Serializer>[] serializers = new Class[0]; private Class<?>[] children = {}; + private Child[] childrenDefs = {}; private Class<?>[] mixins = {}; private Mixin[] mixinDefs = {}; private Class<?>[] parsers = {}; @@ -216,6 +217,17 @@ public class RestAnnotation { return this; } + /** + * Sets the {@link Rest#childrenDefs()} property on this annotation. + * + * @param value The new value for this property. + * @return This object. + */ + public Builder childrenDefs(Child...value) { + childrenDefs = value; + return this; + } + /** * Sets the {@link Rest#mixins()} property on this annotation. * @@ -806,6 +818,7 @@ public class RestAnnotation { private final Debug debug; private final Class<? extends Serializer>[] serializers; private final Class<?>[] children; + private final Child[] childrenDefs; private final Class<?>[] mixins; private final Mixin[] mixinDefs; private final Class<?>[] parsers; @@ -862,6 +875,7 @@ public class RestAnnotation { callLogger = b.callLogger; authenticator = b.authenticator; children = cp(b.children); + childrenDefs = cp(b.childrenDefs); mixins = cp(b.mixins); mixinDefs = cp(b.mixinDefs); clientVersionHeader = b.clientVersionHeader; @@ -947,6 +961,11 @@ public class RestAnnotation { return children; } + @Override /* Overridden from Rest */ + public Child[] childrenDefs() { + return childrenDefs; + } + @Override /* Overridden from Rest */ public Class<?>[] mixins() { return mixins; diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestChildren.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestChildren.java index 47dab35d5c..abb3e6b191 100644 --- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestChildren.java +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestChildren.java @@ -71,6 +71,7 @@ public class RestChildren { */ static class LazyChildEntry { + final RestContext.ResolvedChild resolvedChild; final Class<?> resourceClass; final String path; final UrlPathMatcher pathMatcher; @@ -86,8 +87,9 @@ public class RestChildren { private final Object lock = new Object(); - LazyChildEntry(Class<?> resourceClass, String path, RestContext parent, BeanStore beanStore, ServletConfig servletConfig) { - this.resourceClass = resourceClass; + LazyChildEntry(RestContext.ResolvedChild resolvedChild, String path, RestContext parent, BeanStore beanStore, ServletConfig servletConfig) { + this.resolvedChild = resolvedChild; + this.resourceClass = resolvedChild.type(); this.path = path; var p = path; if (! p.endsWith("/*")) @@ -120,7 +122,7 @@ public class RestChildren { return rc; LOGGER.info(() -> "Lazy REST child materializing: " + resourceClass.getName() + " at path '" + path + "'"); try { - rc = buildChildContext(parent, beanStore, servletConfig, resourceClass, null, ""); + rc = buildChildContext(parent, beanStore, servletConfig, resolvedChild, null, ""); rc.postInit(); rc.postInitChildFirst(); } catch (Exception e) { @@ -215,14 +217,41 @@ public class RestChildren { * The path is pre-resolved from the resource class so that routing works immediately at parent startup. * The full {@link RestContext} is built on the first matching request. * + * <p> + * Delegates to {@link #addLazy(RestContext.ResolvedChild, String)} with a bare (no-seed) declaration — + * this public overload's signature is preserved unchanged (it has no external-caller-visible way to + * express a host-declared {@code @Child} seed; see that overload for the seed-carrying entry point used + * internally by {@link RestContext}'s {@code childrenDefs} discovery). + * * @param resourceClass The resource class to materialize lazily. Must not be {@code null}. * @param path The pre-resolved path prefix (without leading slash). Use {@code ""} to read from * {@link Rest#path() @Rest(path)} on the resource class. * @return This object. */ public Builder addLazy(Class<?> resourceClass, String path) { + return addLazy(RestContext.ResolvedChild.ofBare(resourceClass), path); + } + + /** + * Registers a lazy child resource entry carrying a normalized child declaration, which may include a + * host-declared {@code @Child} seed. + * + * <p> + * The path is pre-resolved from the resource class so that routing works immediately at parent startup. + * The full {@link RestContext} is built on the first matching request, with the seed (if any) threaded + * through to {@link RestChildren#buildChildContext} at that point — so a lazily-materialized child + * receives its host-seeded settings identically to an eagerly-built one. + * + * @param resolvedChild The normalized child declaration (class plus any host-declared seed). Must not be + * {@code null}. + * @param path The pre-resolved path prefix (without leading slash). Use {@code ""} to read from + * {@link Rest#path() @Rest(path)} on the resource class. + * @return This object. + */ + Builder addLazy(RestContext.ResolvedChild resolvedChild, String path) { + var resourceClass = resolvedChild.type(); var resolvedPath = isNotEmpty(path) ? trimLeadingSlashes(path) : LazyChildEntry.resolvePathForClass(resourceClass); - lazyList.add(new LazyChildEntry(resourceClass, resolvedPath, parent, beanStore, servletConfig)); + lazyList.add(new LazyChildEntry(resolvedChild, resolvedPath, parent, beanStore, servletConfig)); return this; } @@ -553,7 +582,8 @@ public class RestChildren { * @param parent The parent {@link RestContext}. Must not be {@code null}. * @param beanStore The bean store to resolve instances and dependencies from. Must not be {@code null}. * @param servletConfig The {@link ServletConfig} to pass through to the child. May be {@code null}. - * @param resourceClass The resource class. Must not be {@code null}. + * @param resolvedChild The normalized child declaration (class plus any host-declared {@code @Child} seed). + * Must not be {@code null}. * @param resourceInstance A pre-supplied instance of the resource. If {@code null}, the instance is resolved * from the bean store or freshly instantiated via {@link BeanInstantiator}. * @param pathOverride An explicit path segment (relative to the parent). Use {@code ""} to read the path from @@ -565,9 +595,10 @@ public class RestChildren { RestContext parent, BeanStore beanStore, ServletConfig servletConfig, - Class<?> resourceClass, + RestContext.ResolvedChild resolvedChild, Object resourceInstance, String pathOverride) throws Exception { + var resourceClass = resolvedChild.type(); Supplier<?> so; if (resourceInstance != null) { final Object r = resourceInstance; @@ -578,7 +609,7 @@ public class RestChildren { Object o = BeanInstantiator.of(resourceClass, beanStore).run(); so = () -> o; } - var cc = new RestContext(new RestContext.Args(resourceClass, parent, servletConfig, so, pathOverride, null, null, null, RestContext.ContextKind.CHILD)); + var cc = new RestContext(new RestContext.Args(resourceClass, parent, servletConfig, so, pathOverride, null, null, null, new RestContext.ContextKind.Child(resolvedChild))); var mi = ClassInfo.of(so.get()) .getMethod(x -> x.hasName("setContext") && x.hasParameterTypes(RestContext.class)) .orElse(null); @@ -592,7 +623,8 @@ public class RestChildren { throw isex("Cannot add a child to a RestChildren that was not initialized with a parent RestContext."); RestContext cc; try { - cc = buildChildContext(parent, beanStore, servletConfig, resourceClass, resourceInstance, pathOverride); + // The dynamic-add API has no seed — a bare ResolvedChild preserves its existing no-override behavior. + cc = buildChildContext(parent, beanStore, servletConfig, RestContext.ResolvedChild.ofBare(resourceClass), resourceInstance, pathOverride); } catch (Exception e) { throw new ServletException("Failed to build child REST context for " + resourceClass.getName(), unwrapThrowable(e)); } diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java index 5ebaacb114..9386a298ce 100644 --- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java @@ -222,9 +222,10 @@ public class RestContext extends Context { * {@code getPaths()} getter, then {@link Rest#paths()} (SVL-resolved per element and comma-split). * An empty array explicitly clears the mount list (no top-level mounts). * @param kind The build-time {@link ContextKind kind} of this context — {@link ContextKind#ROOT ROOT} for a - * top-level/host/mock resource, {@link ContextKind#CHILD CHILD} for a {@link Rest#children() @Rest(children)} - * sub-resource (isolated resolution), or a {@link ContextKind.Mixin MIXIN} carrying the {@link ResolvedMixin} - * that produced a per-mixin sub-context (parent-linked to the host so that + * top-level/host/mock resource, a {@link ContextKind.Child CHILD} carrying the {@link ResolvedChild} for a + * {@link Rest#children() @Rest(children)}/{@link Rest#childrenDefs() childrenDefs} sub-resource (isolated + * resolution, with an optional host-seeded payload), or a {@link ContextKind.Mixin MIXIN} carrying the + * {@link ResolvedMixin} that produced a per-mixin sub-context (parent-linked to the host so that * {@link RestContext#getRestAnnotationsForProperty(String) annotation-property walks} prepend the host's * {@code @Rest} chain before the mixin's own). * @@ -354,6 +355,62 @@ public class RestContext extends Context { } } + /** + * Normalized carrier for a routed child, produced by discovery from either a bare {@link Rest#children()} + * class or a rich {@link Rest#childrenDefs() @Child} declaration — the exact structural analog of + * {@link ResolvedMixin}. + * + * <p> + * A bare class is normalized to {@code new ResolvedChild(type, ChildAnnotation.DEFAULT)} (i.e. + * {@code @Child(type=X)} with an empty seed), so a bare class and an empty-seed {@code @Child} are + * structurally identical — the same equivalence {@link ResolvedMixin} establishes for mixins. + * + * @param type The child class. + * @param seed The host-declared seed annotation, or {@link ChildAnnotation#DEFAULT} for a bare class. + * + * @since 10.0.0 + */ + public static record ResolvedChild(Class<?> type, Child seed) { + + /** + * Compact canonical constructor — validates required components and null-coalesces {@code seed}. + */ + public ResolvedChild { + assertArgNotNull("type", type); + if (seed == null) + seed = ChildAnnotation.DEFAULT; + } + + /** + * Normalizes a bare child class to a {@link ResolvedChild} with no seed. + * + * @param type The child class. + * @return A {@link ResolvedChild} carrying {@link ChildAnnotation#DEFAULT} as its seed. + */ + static ResolvedChild ofBare(Class<?> type) { + return new ResolvedChild(type, ChildAnnotation.DEFAULT); + } + + /** + * Normalizes a rich {@code @Child} declaration to a {@link ResolvedChild}. + * + * @param c The {@code @Child} annotation. + * @return A {@link ResolvedChild} carrying {@code c} as its seed. + */ + static ResolvedChild ofDef(Child c) { + return new ResolvedChild(c.type(), c); + } + + /** + * Returns {@code true} when this child carries no host-declared seed (i.e. is equivalent to a bare class). + * + * @return {@code true} if the seed payload is {@link ChildAnnotation#DEFAULT}. + */ + boolean hasNoSeed() { + return seed == ChildAnnotation.DEFAULT; + } + } + /** * The build-time <i>kind</i> of a {@link RestContext} — which of the three construction flavors produced it. * @@ -368,11 +425,14 @@ public class RestContext extends Context { * parent-linked to the host and <i>inherits</i> the host's {@code @Rest} annotation chain * ({@link #getRestAnnotationsForProperty(String)} walks host→mixin), so {@code @Mixin} overrides * <i>layer on top of</i> an inherited chain. A {@link Child} is <i>isolated</i> ({@link #HOST_ONLY_PROPERTIES} - * includes {@code children}; children retain pre-10.0.0 isolated resolution with no parent walk). So a future - * {@code @Child} would <i>seed</i> settings onto an isolated context rather than <i>override</i> an inherited - * chain, and would have no {@code noInherit} analog — it must NOT reuse the mixin override-resolution path. - * The {@code Child} variant is intentionally shaped so it can later widen to reference a {@code ResolvedChild} - * (mirroring how {@code Mixin} references a {@code ResolvedMixin}) without re-plumbing this discriminator. + * includes {@link RestServerConstants#PROPERTY_children}; children retain pre-10.0.0 isolated resolution with + * no parent walk). A {@code @Child} seed therefore does not <i>override</i> an inherited chain the way a + * {@code @Mixin} override does — instead, {@link #computeRawRestAnnotations()} injects the seed's synthetic + * {@code @Rest} at the <b>least-derived</b> slot of the child's own (still fully isolated) annotation chain, + * between the child's real {@code @Rest} declarations and the synthesized {@link + * org.apache.juneau.rest.server.config.DefaultConfig DefaultConfig} fallback. This ranks the seed below + * everything the child itself declares (so the child's own explicit value wins for child-wins scalars, and + * the child's own {@code noInherit} cutoff naturally cuts the seed too) but above the framework defaults. * * @since 10.0.0 */ @@ -381,8 +441,14 @@ public class RestContext extends Context { /** A top-level (host) resource, mock context, or servlet-mounted resource. */ record Root() implements ContextKind {} - /** A routed child resource (mounted via {@link Rest#children() @Rest(children)}); isolated resolution. */ - record Child() implements ContextKind {} + /** + * A routed child resource (mounted via {@link Rest#children()}/{@link Rest#childrenDefs()}); isolated + * resolution, with an optional host-seeded {@code @Child} payload injected at the least-derived slot of + * its own annotation chain (see the class javadoc above). + * + * @param def The normalized declaration that produced this sub-context — carries the host-declared seed payload. + */ + record Child(ResolvedChild def) implements ContextKind {} /** * A per-mixin sub-context (composed via {@link Rest#mixins()}/{@link Rest#mixinDefs()}); inherits the host chain. @@ -393,9 +459,6 @@ public class RestContext extends Context { /** Shared {@link Root} singleton (the common case). */ ContextKind ROOT = new Root(); - - /** Shared {@link Child} singleton (the path override travels in {@link Args#path()}, not here). */ - ContextKind CHILD = new Child(); } /** @@ -836,6 +899,8 @@ public class RestContext extends Context { protected final ContextKind contextKind; /** The normalized mixin declaration that produced this sub-context, or {@code null} when not a mixin context. */ protected final ResolvedMixin resolvedMixin; + /** The normalized child declaration that produced this sub-context, or {@code null} when not a child context. */ + protected final ResolvedChild resolvedChild; protected final String fullPath; protected final String path; protected final String[] paths; @@ -875,6 +940,15 @@ public class RestContext extends Context { */ private ResolvedMixin resolvedMixinField() { return resolvedMixin; } + /** + * Field accessor for {@link #resolvedChild}, callable from memoizer field-initializer lambdas (which run + * before the blank-final field is read directly). Returns the normalized child declaration that produced + * this sub-context, or {@code null} when this is not a child context. + * + * @return The resolved child, or {@code null}. + */ + private ResolvedChild resolvedChildField() { return resolvedChild; } + /** * Creates the bean store for this context. * @@ -2390,15 +2464,61 @@ public class RestContext extends Context { } } + /** + * Discovers all children declared on this resource's {@code @Rest} chain, normalized to {@link ResolvedChild}. + * + * <p> + * Reads both {@link Rest#children() bare-class} and {@link Rest#childrenDefs() rich @Child} entries (bare + * first, then {@code childrenDefs}). First occurrence of a class wins for ordering; a rich {@code @Child} + * <b>upgrades</b> an earlier bare entry for the same class (a bare class is just {@code @Child(type=X)} with + * an empty seed, so upgrading only adds the host-declared seed). + * + * <p> + * Deliberately <b>non-recursive</b>, unlike {@link #getResolvedMixins()}: children do not flatten transitively + * to the host — each child resolves its own nested {@code @Rest(children=...)}/{@code childrenDefs} independently + * when its own {@link RestContext} is constructed. + * + * @return The ordered, de-duplicated normalized children, never {@code null}. + */ + private Collection<ResolvedChild> getResolvedChildren() { + var out = new LinkedHashMap<Class<?>,ResolvedChild>(); + getRestAnnotations().forEach(ai -> { + for (var c : ai.inner().children()) + collectResolvedChild(ResolvedChild.ofBare(c), out); + for (var def : ai.inner().childrenDefs()) + collectResolvedChild(ResolvedChild.ofDef(def), out); + }); + return out.values(); + } + + private void collectResolvedChild(ResolvedChild rc, LinkedHashMap<Class<?>,ResolvedChild> out) { + var type = rc.type(); + // type==null is defensive only — @Child.type() is required and a declarative @Child requires a class + // literal, so it is never null in practice. type==resourceClass() guards a self-reference (skip to + // avoid an infinite loop). + if (type == null || type == resourceClass()) // HTT: the null arm is not reachable via the annotation API. + return; + var existing = out.get(type); + if (existing != null) { + // Already discovered. A rich @Child upgrades a bare entry (in place, preserving position); otherwise + // the first occurrence wins. No recursion into the child's own nested children (see javadoc above). + if (existing.hasNoSeed() && ! rc.hasNoSeed()) + out.put(type, rc); + return; + } + out.put(type, rc); // No recursion into type's own children — each child owns its own subtree. + } + /** * The {@link RestChildren} for this resource — child {@link RestContext} instances registered via - * {@link Rest#children() @Rest(children)}. + * {@link Rest#children() @Rest(children)}/{@link Rest#childrenDefs() childrenDefs}. * * <p> - * Reads child classes directly from the {@code @Rest(children)} annotation chain (parent-to-child), - * deduplicates, instantiates each child via the bean store, and builds a {@link RestContext} for each. - * Eagerly initialized in the constructor (via an explicit {@code .get()} call inside the try-catch - * block) so that any construction failure surfaces at initialization time rather than lazily. + * Reads children (bare and rich) directly from the {@code @Rest} annotation chain via + * {@link #getResolvedChildren()}, instantiates each child via the bean store, and builds a {@link RestContext} + * for each, threading through any host-declared {@code @Child} seed. Eagerly initialized in the constructor + * (via an explicit {@code .get()} call inside the try-catch block) so that any construction failure surfaces + * at initialization time rather than lazily. */ @SuppressWarnings({ "java:S3776" // cognitive complexity acceptable for child-context construction @@ -2408,22 +2528,16 @@ public class RestContext extends Context { var servletConfig = bs.getBean(ServletConfig.class).orElse(null); var b = RestChildren.create(this, bs, servletConfig); - // Collect child classes from @Rest(children) on the annotation chain (parent-to-child order). - // Deduplicate so the same child class registered on both a parent and child annotation - // doesn't create two contexts. - var seen = new LinkedHashSet<Class<?>>(); - getRestAnnotations().forEach(ai -> seen.addAll(Arrays.asList(ai.inner().children()))); - var lazy = isLazyChildren(); - for (var rc2 : seen) { - if (rc2 == resourceClass()) - continue; // Guard against self-reference infinite loop. + for (var rc2 : getResolvedChildren()) { if (lazy) { // Lazy: register a routing stub now; defer full RestContext construction to first request. + // The stub carries the full ResolvedChild (including any host-declared @Child seed), so a + // lazily-materialized child receives its seed on first invocation identically to an eager one. b.addLazy(rc2, ""); } else { - // Eager (default): build the full child RestContext immediately. + // Eager (default): build the full child RestContext immediately, seed included. b.add(RestChildren.buildChildContext(this, bs, servletConfig, rc2, null, "")); } } @@ -2474,6 +2588,7 @@ public class RestContext extends Context { contextKind = builder.args.kind(); isMixinContext = contextKind instanceof ContextKind.Mixin; resolvedMixin = (contextKind instanceof ContextKind.Mixin contextKind2) ? contextKind2.def() : null; + resolvedChild = (contextKind instanceof ContextKind.Child contextKind3) ? contextKind3.def() : null; resourceClass = builder.resourceClass; var rs = new ResourceSupplier(resourceClass, assertArgNotNull("resource", builder.args.resource())); resource = rs; @@ -2880,6 +2995,51 @@ public class RestContext extends Context { return b.build(); } + /** + * The host-declared {@code @Child} seed values translated into a synthetic {@link Rest} annotation, for a + * child sub-context that carries a seed. {@code null} for non-child contexts and for children with no seed + * (bare classes). + * + * <p> + * Built once at memoizer-init time (zero per-request cost). Unlike {@link #mixinOverrideAnnotation}, this is + * injected at the <b>least-derived</b> slot of {@link #computeRawRestAnnotations()} — below the child's own + * real {@code @Rest} declarations but above the synthesized {@code DefaultConfig} fallback — so the seed acts + * as a default/fallback the child's own explicit configuration wins over (see {@link ContextKind} javadoc). + */ + private final Memoizer<AnnotationInfo<Rest>> childSeedAnnotation = memoizer(() -> { + var rc = resolvedChildField(); + if (rc == null || rc.hasNoSeed()) + return null; + var rest = buildChildSeedRest(rc.seed()); + return AnnotationInfo.of(ClassInfo.of(getResourceClass()), rest); + }); + + /** + * Translates a host-declared {@link Child @Child} into a synthetic {@link Rest @Rest} carrying only its + * seed slots (the §3 scope subset — no {@code encoders}/{@code serializers}/{@code parsers}/ + * {@code responseProcessors}/{@code restOpArgs}/{@code messages}/default headers or attributes/ + * {@code produces}/{@code consumes}), so the standard {@code @Rest} resolution chain applies the seed uniformly. + * + * @param c The {@code @Child} seed declaration. + * @return A synthetic {@code @Rest} populated from {@code c}'s seed slots. + */ + private static Rest buildChildSeedRest(Child c) { + // @formatter:off + var b = RestAnnotation.create() + .guards(c.guards()) + .roleGuard(c.roleGuard()) + .rolesDeclared(c.rolesDeclared()) + .converters(c.converters()) + .callLogger(c.callLogger()) + .partSerializer(c.partSerializer()) + .partParser(c.partParser()) + .debug(c.debug()) + .defaultCharset(c.defaultCharset()) + .maxInput(c.maxInput()); + // @formatter:on + return b.build(); + } + /** * Memoized list of every {@link Rest} annotation on the resource class and its supertypes, in child-to-parent order. * @@ -2906,7 +3066,16 @@ public class RestContext extends Context { /** * Computes the raw {@code @Rest} annotation chain (most-derived first), folding in the framework - * {@code DefaultConfig} annotations for non-mixin contexts. Does not include the synthetic builder annotation. + * {@code DefaultConfig} annotations for non-mixin contexts, and — for a {@linkplain ContextKind.Child child} + * context carrying a host-declared {@code @Child} seed — injecting the seed's synthetic {@code @Rest} at the + * least-derived slot: {@code raw ++ [seed] ++ defaultConfigAnnotations}. This ranks the seed below everything + * the child itself declares (child-wins scalars resolve to the child's own value when present) but above the + * framework's {@code DefaultConfig} fallback (the seed is never masked by framework defaults). This holds + * uniformly whether {@code DefaultConfig} is separately synthesized (the common case) or already embedded + * within {@code raw} because the child class transitively implements {@code DefaultConfig} itself (e.g. by + * extending {@code BasicRestServlet}/{@code BasicRestServletGroup}) — in the embedded case, {@code raw} is + * split around the embedded {@code DefaultConfig} entry/entries so the seed still lands between the child's + * own declarations and that entry, rather than after it. Does not include the synthetic builder annotation. * * @return The raw annotation chain. */ @@ -2914,17 +3083,49 @@ public class RestContext extends Context { var raw = getAnnotationProvider().find(Rest.class, ClassInfo.of(getResourceClass())); if (isMixinContextField()) return raw; - var hasDefaultConfig = raw.stream().anyMatch(ai -> ai.getAnnotatable() instanceof ClassInfo ci && DefaultConfig.class.equals(ci.inner())); - if (hasDefaultConfig) - return raw; + var seed = childSeedAnnotation.get(); // null for non-child contexts and no-seed children + // The framework's DefaultConfig fallback is synthesized/appended for EVERY non-mixin context that + // doesn't already transitively implement DefaultConfig itself — this happens unconditionally, + // regardless of whether this context also carries an @Child seed (that baseline behavior predates + // and is independent of @Child). Partition raw into "the resource's own real declarations" and any + // DefaultConfig entries already embedded within its own hierarchy (e.g. because it extends + // BasicRestServlet/BasicRestServletGroup, which implement DefaultConfig), so that when a seed IS + // present it can always be inserted between them — below everything the child itself declares, but + // above DefaultConfig's fallback values, whether that fallback is embedded here or synthesized below. + var embeddedDefaultConfig = raw.stream().filter(ai -> ai.getAnnotatable() instanceof ClassInfo ci && DefaultConfig.class.equals(ci.inner())).toList(); + if (!embeddedDefaultConfig.isEmpty()) { + if (seed == null) + return raw; + var ownDeclarations = raw.stream().filter(ai -> !(ai.getAnnotatable() instanceof ClassInfo ci && DefaultConfig.class.equals(ci.inner()))).toList(); + var combined = new ArrayList<AnnotationInfo<Rest>>(raw.size() + 1); + combined.addAll(ownDeclarations); + combined.add(seed); + combined.addAll(embeddedDefaultConfig); + return u(combined); + } var defaultConfigAnnotations = getAnnotationProvider().find(Rest.class, ClassInfo.of(DefaultConfig.class)); if (defaultConfigAnnotations.isEmpty()) - return raw; + return seed == null ? raw : appendAnnotation(raw, seed); var combined = new ArrayList<>(raw); + if (seed != null) + combined.add(seed); combined.addAll(defaultConfigAnnotations); return u(combined); } + /** + * Returns a new unmodifiable list with {@code extra} appended after {@code base}. + * + * @param base The base list. + * @param extra The entry to append. + * @return An unmodifiable list of {@code base} followed by {@code extra}. + */ + private static List<AnnotationInfo<Rest>> appendAnnotation(List<AnnotationInfo<Rest>> base, AnnotationInfo<Rest> extra) { + var combined = new ArrayList<>(base); + combined.add(extra); + return u(combined); + } + /** * Prepends the synthetic builder-supplied {@code @Rest} annotation at the most-derived (child) * position so its set members win in both child-first ({@code findFirst}) and parent-to-child @@ -3281,7 +3482,7 @@ public class RestContext extends Context { * preserves that invariant when a mixin sub-context walks annotations for these properties. */ private static final Set<String> HOST_ONLY_PROPERTIES = Set.of( - PROPERTY_path, PROPERTY_paths, PROPERTY_mixins, "children" + PROPERTY_path, PROPERTY_paths, PROPERTY_mixins, PROPERTY_children ); /** @@ -3484,6 +3685,9 @@ public class RestContext extends Context { * @param s The raw string. Can be {@code null}. * @return The resolved string. */ + @SuppressWarnings({ + "java:S2259" // getVarResolver() is never null post-construction: registerFrameworkDefaults() always registers a VarResolver default supplier before the constructor returns. + }) protected String resolve(String s) { return getVarResolver().resolve(s); } @@ -4144,6 +4348,9 @@ public class RestContext extends Context { * * @return The context statistics. */ + @SuppressWarnings({ + "java:S2259" // getMethodExecStore() is never null post-construction: registerFrameworkDefaults() always registers a MethodExecStore default supplier before the constructor returns. + }) public RestContextStats getStats() { return new RestContextStats(startTime, getMethodExecStore().getStatsByTotalTime()); } /** @@ -4548,6 +4755,9 @@ public class RestContext extends Context { return this; } + @SuppressWarnings({ + "java:S2259" // mi is guarded by the nn(mi) check above; Sonar's flow analysis does not track nn() as a null guard. + }) private void initializeResourceContext(Object resource2) { var mi = ClassInfo.of(resource2).getMethod(x -> x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null); if (nn(mi)) { @@ -4565,6 +4775,9 @@ public class RestContext extends Context { * @return This object. * @throws ServletException Error occurred. */ + @SuppressWarnings({ + "java:S2259" // getRestChildren() is never null post-construction: registerFrameworkDefaults() always registers a RestChildren default supplier before the constructor returns. + }) public RestContext postInitChildFirst() throws ServletException { if (initialized.get()) return this; @@ -4580,6 +4793,9 @@ public class RestContext extends Context { return this; } + @SuppressWarnings({ + "java:S2259" // getDebugConfig() is never null post-construction: registerFrameworkDefaults() always registers a DebugConfig default supplier before the constructor returns. + }) private boolean isDebug(RestSession call) { return getDebugConfig().resolve(this, call.getRequest()).enabled(); } @@ -4797,6 +5013,9 @@ public class RestContext extends Context { * @param m The method to get statistics for. * @return The cached time-stats object. */ + @SuppressWarnings({ + "java:S2259" // getMethodExecStore() is never null post-construction: registerFrameworkDefaults() always registers a MethodExecStore default supplier before the constructor returns. + }) protected MethodExecStats getMethodExecStats(Method m) { return getMethodExecStore().getStats(m); } @@ -5071,7 +5290,8 @@ public class RestContext extends Context { * @throws NotImplemented No registered response processors could handle the call. */ @SuppressWarnings({ - "java:S127" // Loop counter i resets to -1 on RESTART + "java:S127", // Loop counter i resets to -1 on RESTART + "java:S2259" // getResponseProcessors() is never null post-construction: registerFrameworkDefaults() always registers a ResponseProcessor[] default supplier before the constructor returns. }) public void processResponse(RestOpSession opSession) throws IOException, BasicHttpException, NotImplemented { diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestServerConstants.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestServerConstants.java index 38d2897a63..345a2200c8 100644 --- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestServerConstants.java +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestServerConstants.java @@ -232,6 +232,9 @@ public final class RestServerConstants { /** The {@code "mixins"} annotation attribute name — used in {@code noInherit} matching on {@code @Rest} annotations to cut off the class-chain walk when resolving operation mixins. */ public static final String PROPERTY_mixins = "mixins"; + /** The {@code "children"} annotation attribute name — used in {@code noInherit} matching on {@code @Rest} annotations to cut off the class-chain walk when resolving routed children. */ + public static final String PROPERTY_children = "children"; + /** The {@code "value"} annotation attribute name — used by {@code @RestOp}/verb annotations to hold the (optional method-prefixed) path; folded into {@link #PROPERTY_path}. */ public static final String PROPERTY_value = "value";
