From b7acf30c89600266c792c6d4b14c2c5f1f5ec624 Mon Sep 17 00:00:00 2001 From: KochTobi Date: Mon, 17 Nov 2025 09:54:41 +0100 Subject: [PATCH 01/20] Fix value injection in DataManagerLayout.java --- .../java/life/qbic/datamanager/views/DataManagerLayout.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java index 85e13b5ed..3f8902294 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java @@ -11,6 +11,7 @@ import life.qbic.datamanager.announcements.AnnouncementComponent; import life.qbic.datamanager.announcements.AnnouncementService; import life.qbic.datamanager.views.general.footer.FooterComponentFactory; +import org.springframework.beans.factory.annotation.Value; /** * Data Manager Layout @@ -25,8 +26,8 @@ public class DataManagerLayout extends AppLayout implements RouterLayout { protected DataManagerLayout(FooterComponentFactory footerComponentFactory, AnnouncementService announcementService, - Duration initialDelay, - Duration refreshInterval) { + @Value(value = "${announcement.initial-delay}") Duration initialDelay, + @Value(value = "${announcement.refresh-interval}") Duration refreshInterval) { Objects.requireNonNull(footerComponentFactory); setId("data-manager-layout"); // Create content area From 29ce0f78833ecd136966807a833769591540133c Mon Sep 17 00:00:00 2001 From: KochTobi Date: Mon, 17 Nov 2025 13:58:47 +0100 Subject: [PATCH 02/20] add getters --- .../announcements/AnnouncementRepository.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java index be089d970..65114acac 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java @@ -29,6 +29,14 @@ class Announcement { @Column(name = "message") private String message; + protected Announcement(Long id, Instant displayStartTime, Instant displayEndTime, + String message) { + this.id = id; + this.displayStartTime = displayStartTime; + this.displayEndTime = displayEndTime; + this.message = message; + } + public Long getId() { return id; } @@ -40,6 +48,22 @@ public void setId(Long id) { public String getMessage() { return message; } + + public Instant getDisplayStartTime() { + return displayStartTime; + } + + public void setDisplayStartTime(Instant displayStartTime) { + this.displayStartTime = displayStartTime; + } + + public Instant getDisplayEndTime() { + return displayEndTime; + } + + public void setDisplayEndTime(Instant displayEndTime) { + this.displayEndTime = displayEndTime; + } } List getAnnouncementByDisplayStartTimeBeforeAndDisplayEndTimeAfterOrderByDisplayStartTimeAsc( From f0bf245ec83a9c114850db2776f073629ba5bef8 Mon Sep 17 00:00:00 2001 From: KochTobi Date: Mon, 17 Nov 2025 14:00:23 +0100 Subject: [PATCH 03/20] Introduce hot flux with announcements --- datamanager-app/pom.xml | 5 ++ .../announcements/AnnouncementService.java | 30 +++++++ .../AnnouncementServiceImpl.java | 29 ++++++- .../AnnouncementServiceImplTest.java | 78 +++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 datamanager-app/src/test/groovy/life/qbic/datamanager/announcements/AnnouncementServiceImplTest.java diff --git a/datamanager-app/pom.xml b/datamanager-app/pom.xml index cebe70bf5..ceaab0707 100644 --- a/datamanager-app/pom.xml +++ b/datamanager-app/pom.xml @@ -194,6 +194,11 @@ spock-core test + + io.projectreactor + reactor-test + test + life.qbic.datamanager identity diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java index 9952b0fb1..4def92f5e 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java @@ -1,6 +1,7 @@ package life.qbic.datamanager.announcements; import java.time.Instant; +import java.util.List; import reactor.core.publisher.Flux; /** @@ -17,6 +18,14 @@ public interface AnnouncementService { */ Flux loadActiveAnnouncements(Instant timePoint); + /** + * A {@link Flux} containing announcements. Each bundle contains announcements active at the given time. The Flux is a hot source. + * Every subscriber gets the latest 1 AnnouncementBundle and all following bundles. At least one subscription is required for the flux to connect. + * + * @return a hot flux publishing bundled announcements + */ + Flux activeAnnouncements(); + /** * An announcement with a given message. * @@ -25,4 +34,25 @@ public interface AnnouncementService { record Announcement(String message) { } + + /** + * A bundle of announcements. The announcement list contained within is unmodifiable. + * + * @param announcements a list of announcements. The provided list is copied into an unmodifiable + * list. + */ + record AnnouncementBundle(List announcements) { + + public AnnouncementBundle { + announcements = List.copyOf(announcements); + } + + static AnnouncementBundle empty() { + return new AnnouncementBundle(List.of()); + } + + public boolean isEmpty() { + return announcements.isEmpty(); + } + } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementServiceImpl.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementServiceImpl.java index 90578c6aa..f8b044649 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementServiceImpl.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementServiceImpl.java @@ -1,8 +1,11 @@ package life.qbic.datamanager.announcements; +import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Objects; import life.qbic.projectmanagement.application.concurrent.VirtualThreadScheduler; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; @@ -10,9 +13,15 @@ public class AnnouncementServiceImpl implements AnnouncementService { private final AnnouncementRepository announcementRepository; + private final Duration initialDelay; + private final Duration refreshInterval; - public AnnouncementServiceImpl(AnnouncementRepository announcementRepository) { + public AnnouncementServiceImpl(AnnouncementRepository announcementRepository, + @Value(value = "${announcement.initial-delay}") Duration initialDelay, + @Value(value = "${announcement.refresh-interval}") Duration refreshInterval) { this.announcementRepository = announcementRepository; + this.initialDelay = initialDelay; + this.refreshInterval = refreshInterval; } @Override @@ -25,6 +34,24 @@ public Flux loadActiveAnnouncements(Instant timePoint) { .subscribeOn(VirtualThreadScheduler.getScheduler()); } + private List foobar(Instant timePoint) { + return announcementRepository.getAnnouncementByDisplayStartTimeBeforeAndDisplayEndTimeAfterOrderByDisplayStartTimeAsc( + timePoint, timePoint) + .stream() + .map(AnnouncementServiceImpl::toApiObject) + .distinct() + .toList(); + } + + @Override + public Flux activeAnnouncements() { + return Flux.interval(initialDelay, refreshInterval) + .map(ignored -> foobar(Instant.now())) + .map(AnnouncementBundle::new) + .replay(1) // every subscriber gets the last bundle directly + .autoConnect(); + } + private static Announcement toApiObject(AnnouncementRepository.Announcement announcement) { return new Announcement(announcement.getMessage()); } diff --git a/datamanager-app/src/test/groovy/life/qbic/datamanager/announcements/AnnouncementServiceImplTest.java b/datamanager-app/src/test/groovy/life/qbic/datamanager/announcements/AnnouncementServiceImplTest.java new file mode 100644 index 000000000..986506296 --- /dev/null +++ b/datamanager-app/src/test/groovy/life/qbic/datamanager/announcements/AnnouncementServiceImplTest.java @@ -0,0 +1,78 @@ +package life.qbic.datamanager.announcements; + +import static java.lang.Thread.sleep; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AnnouncementServiceImplTest { + + static class MockRepo implements AnnouncementRepository { + + static List announcements = new ArrayList<>(); + + public MockRepo() { + Instant time = Instant.now().plus(Duration.ofMillis(50)); + announcements.add(0, + new Announcement(0L, time.minus(Duration.ofDays(1)), time.minus(Duration.ofMillis(50)), + "An announcement -1d to -50ms") + ); + announcements.add(0, + new Announcement(0L, time.minus(Duration.ofDays(1)), time.plus(Duration.ofMillis(200)), + "An announcement -1d to +50ms") + ); + announcements.add(0, + new Announcement(0L, time.plus(Duration.ofMillis(0)), time.plus(Duration.ofMillis(250)), + "An announcement 0s to +50ms") + ); + announcements.add(0, + new Announcement(0L, time.plus(Duration.ofMillis(50)), time.plus(Duration.ofMillis(300)), + "An announcement +50ms to +100ms") + ); + announcements.add(0, + new Announcement(0L, time.plus(Duration.ofMillis(50)), time.plus(Duration.ofMillis(300)), + "An announcement +50ms to +200ms") + ); + } + + @Override + public List getAnnouncementByDisplayStartTimeBeforeAndDisplayEndTimeAfterOrderByDisplayStartTimeAsc( + Instant min, Instant max) { + return announcements.stream() + .filter(it -> it.getDisplayStartTime().isBefore(min)) + .filter(it -> it.getDisplayEndTime().isAfter(max)) + .toList(); + } + + } + + + @Test + public void testHotAnnouncementFlux() throws InterruptedException { + AnnouncementRepository repo = new MockRepo(); + AnnouncementServiceImpl announcementService = new AnnouncementServiceImpl( + repo, + Duration.ZERO, + Duration.ofMillis(15) + ); + System.out.println("starting subscription"); + var s1 = announcementService.activeAnnouncements().subscribe( + it -> System.out.println("s1: " + it)); + System.out.println("subscribed s1"); + sleep(15); + var s2 = announcementService.activeAnnouncements().subscribe( + it -> System.out.println("s2: " + it)); + System.out.println("subscribed s2"); + sleep(50); + s1.dispose(); + System.out.println("unsubscribed s1"); + sleep(500); + s2.dispose(); + + System.out.println("unsubscribed s2"); + } + +} From 89c4ea67e9d8efa41ba14c3e76e50ebbac45d40e Mon Sep 17 00:00:00 2001 From: KochTobi Date: Mon, 17 Nov 2025 14:42:47 +0100 Subject: [PATCH 04/20] Switch to hot announcement source --- .../announcements/AnnouncementComponent.java | 75 ++++--------------- .../announcements/AnnouncementRepository.java | 3 + .../announcements/AnnouncementService.java | 11 --- .../AnnouncementServiceImpl.java | 18 ++--- .../datamanager/views/DataManagerLayout.java | 9 +-- .../datamanager/views/UserMainLayout.java | 9 +-- .../views/landing/LandingPageLayout.java | 9 +-- .../projects/project/ProjectMainLayout.java | 9 +-- .../experiments/ExperimentMainLayout.java | 9 +-- 9 files changed, 35 insertions(+), 117 deletions(-) diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementComponent.java index f2b83689e..a5e2e7f79 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementComponent.java @@ -1,79 +1,34 @@ package life.qbic.datamanager.announcements; -import static java.util.Objects.nonNull; -import static java.util.Objects.requireNonNull; - import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.Component; -import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.Html; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.server.VaadinSession; -import java.time.Duration; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import life.qbic.datamanager.announcements.AnnouncementService.Announcement; +import life.qbic.datamanager.announcements.AnnouncementService.AnnouncementBundle; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import reactor.core.Disposable; -import reactor.core.publisher.Flux; public class AnnouncementComponent extends Div { private static final Log log = LogFactory.getLog(AnnouncementComponent.class); private final transient AnnouncementService announcementService; - private final Duration initialDelay; - private final Duration refreshInterval; - private transient Disposable refreshRoutine; - public AnnouncementComponent(AnnouncementService announcementService, Duration initialDelay, - Duration refreshInterval) { + public AnnouncementComponent(AnnouncementService announcementService) { this.announcementService = announcementService; this.setId("announcements"); this.setVisible(false); //without subscribing to announcements nothing is displayed - this.initialDelay = requireNonNull(initialDelay); - this.refreshInterval = requireNonNull(refreshInterval); - } - - private void subscribeToAnnouncements() { - unsubscribeFromAnnouncements(); - UI ui = getUI().orElseThrow(); - refreshRoutine = Flux.interval(initialDelay, refreshInterval) - .doOnNext(it -> ui.access(() -> { - int uiId = ui.getUIId(); - String pushId = Optional.ofNullable(ui.getSession()) - .map(VaadinSession::getPushId) - .orElse( - "N/A"); - String sessionId = Optional.ofNullable(ui.getSession()) - .map(s -> s.getSession().getId()) - .orElse("N/A"); - log.debug( - "Fetching announcements for ui[%s] vaadin[%s] http[%s] ".formatted(uiId, pushId, - sessionId)); - })) - .flatMap(it -> announcementService.loadActiveAnnouncements(Instant.now()) - .collectList()) - .subscribe(announcements -> refreshAnnouncements(announcements, ui)); } - private void unsubscribeFromAnnouncements() { - if (nonNull(refreshRoutine)) { - refreshRoutine.dispose(); - } - } - private void refreshAnnouncements(List announcements, UI ui) { - ui.access(() -> { - this.removeAll(); - this.setVisible(!announcements.isEmpty()); - for (Announcement announcement : announcements) { - add(renderAnnouncement(announcement)); - } + private void refreshAnnouncements(AnnouncementBundle announcementBundle) { + this.removeAll(); + this.setVisible(!announcementBundle.isEmpty()); + announcementBundle.announcements().forEach(announcement -> { + add(renderAnnouncement(announcement)); }); } @@ -88,12 +43,14 @@ private Component renderAnnouncement(AnnouncementService.Announcement announceme @Override protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); - subscribeToAnnouncements(); - } - - @Override - protected void onDetach(DetachEvent detachEvent) { - unsubscribeFromAnnouncements(); - super.onDetach(detachEvent); + UI ui = attachEvent.getUI(); + //attach and detach to a hot strem https://vaadin.com/docs/latest/building-apps/deep-dives/presentation-layer/server-push/reactive + Disposable announcementSubscription = announcementService.activeAnnouncements() + .subscribe(ui.accessLater(this::refreshAnnouncements, null)); + + addDetachListener(detachEvent -> { + detachEvent.unregisterListener(); + announcementSubscription.dispose(); + }); } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java index 65114acac..5994bc877 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java @@ -29,6 +29,9 @@ class Announcement { @Column(name = "message") private String message; + private Announcement() { + } + protected Announcement(Long id, Instant displayStartTime, Instant displayEndTime, String message) { this.id = id; diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java index 4def92f5e..8f2ddd071 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java @@ -1,6 +1,5 @@ package life.qbic.datamanager.announcements; -import java.time.Instant; import java.util.List; import reactor.core.publisher.Flux; @@ -8,16 +7,6 @@ * Loads announcements */ public interface AnnouncementService { - - /** - * A {@link Flux} containing Announcements. Only publishes announcements that are valid given the - * provided time. Published announcements are distinct until changed. - * - * @param timePoint the timepoint at which the announcement is valid - * @return a {@link Flux} publishing announcements - */ - Flux loadActiveAnnouncements(Instant timePoint); - /** * A {@link Flux} containing announcements. Each bundle contains announcements active at the given time. The Flux is a hot source. * Every subscriber gets the latest 1 AnnouncementBundle and all following bundles. At least one subscription is required for the flux to connect. diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementServiceImpl.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementServiceImpl.java index f8b044649..53c22e089 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementServiceImpl.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementServiceImpl.java @@ -3,8 +3,8 @@ import java.time.Duration; import java.time.Instant; import java.util.List; -import java.util.Objects; -import life.qbic.projectmanagement.application.concurrent.VirtualThreadScheduler; +import life.qbic.logging.api.Logger; +import life.qbic.logging.service.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; @@ -16,6 +16,8 @@ public class AnnouncementServiceImpl implements AnnouncementService { private final Duration initialDelay; private final Duration refreshInterval; + private static final Logger log = LoggerFactory.logger(AnnouncementServiceImpl.class); + public AnnouncementServiceImpl(AnnouncementRepository announcementRepository, @Value(value = "${announcement.initial-delay}") Duration initialDelay, @Value(value = "${announcement.refresh-interval}") Duration refreshInterval) { @@ -24,16 +26,6 @@ public AnnouncementServiceImpl(AnnouncementRepository announcementRepository, this.refreshInterval = refreshInterval; } - @Override - public Flux loadActiveAnnouncements(Instant timePoint) { - return Flux.fromIterable( - announcementRepository.getAnnouncementByDisplayStartTimeBeforeAndDisplayEndTimeAfterOrderByDisplayStartTimeAsc( - timePoint, timePoint)) - .distinctUntilChanged(Objects::hashCode) //avoid unnecessary work - .map(AnnouncementServiceImpl::toApiObject) - .subscribeOn(VirtualThreadScheduler.getScheduler()); - } - private List foobar(Instant timePoint) { return announcementRepository.getAnnouncementByDisplayStartTimeBeforeAndDisplayEndTimeAfterOrderByDisplayStartTimeAsc( timePoint, timePoint) @@ -46,8 +38,10 @@ private List foobar(Instant timePoint) { @Override public Flux activeAnnouncements() { return Flux.interval(initialDelay, refreshInterval) + .doOnNext(it -> log.debug("Fetching announcements")) .map(ignored -> foobar(Instant.now())) .map(AnnouncementBundle::new) + .doOnNext(it -> log.debug("Found " + it.announcements().size() + " announcements")) .replay(1) // every subscriber gets the last bundle directly .autoConnect(); } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java index 3f8902294..6dd1ef85e 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java @@ -6,12 +6,10 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.RouterLayout; -import java.time.Duration; import java.util.Objects; import life.qbic.datamanager.announcements.AnnouncementComponent; import life.qbic.datamanager.announcements.AnnouncementService; import life.qbic.datamanager.views.general.footer.FooterComponentFactory; -import org.springframework.beans.factory.annotation.Value; /** * Data Manager Layout @@ -25,16 +23,13 @@ public class DataManagerLayout extends AppLayout implements RouterLayout { private final Div contentArea; protected DataManagerLayout(FooterComponentFactory footerComponentFactory, - AnnouncementService announcementService, - @Value(value = "${announcement.initial-delay}") Duration initialDelay, - @Value(value = "${announcement.refresh-interval}") Duration refreshInterval) { + AnnouncementService announcementService) { Objects.requireNonNull(footerComponentFactory); setId("data-manager-layout"); // Create content area contentArea = new Div(); contentArea.setId("content-area"); - AnnouncementComponent announcementComponent = new AnnouncementComponent(announcementService, - initialDelay, refreshInterval); + AnnouncementComponent announcementComponent = new AnnouncementComponent(announcementService); // Add content area and footer to the main layout Div mainLayout = new Div(announcementComponent, contentArea, footerComponentFactory.get()); mainLayout.setId("main-layout"); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/UserMainLayout.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/UserMainLayout.java index c4c2cb24c..efa350e64 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/UserMainLayout.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/UserMainLayout.java @@ -3,7 +3,6 @@ import com.vaadin.flow.component.html.Span; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.spring.security.AuthenticationContext; -import java.time.Duration; import java.util.Objects; import life.qbic.datamanager.announcements.AnnouncementService; import life.qbic.datamanager.views.account.PersonalAccessTokenMain; @@ -12,7 +11,6 @@ import life.qbic.datamanager.views.projects.overview.ProjectOverviewMain; import life.qbic.identity.api.UserInformationService; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; /** * The user main layout is the layout holding all views outside of an individual project view, @@ -27,11 +25,8 @@ public class UserMainLayout extends DataManagerLayout { public UserMainLayout(@Autowired AuthenticationContext authenticationContext, UserInformationService userInformationService, @Autowired FooterComponentFactory footerComponentFactory, - AnnouncementService announcementService, - @Value(value = "${announcement.initial-delay}") Duration initialDelay, - @Value(value = "${announcement.refresh-interval}") Duration refreshInterval) { - super(Objects.requireNonNull(footerComponentFactory), announcementService, initialDelay, - refreshInterval); + AnnouncementService announcementService) { + super(Objects.requireNonNull(footerComponentFactory), announcementService); Span navBarTitle = new Span("Data Manager"); navBarTitle.setClassName("navbar-title"); addClassName("user-main-layout"); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/landing/LandingPageLayout.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/landing/LandingPageLayout.java index e88ef3c55..414311976 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/landing/LandingPageLayout.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/landing/LandingPageLayout.java @@ -9,14 +9,12 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.RouterLayout; import java.io.Serial; -import java.time.Duration; import java.util.Objects; import life.qbic.datamanager.announcements.AnnouncementService; import life.qbic.datamanager.views.DataManagerLayout; import life.qbic.datamanager.views.LandingPageTitleAndLogo; import life.qbic.datamanager.views.general.footer.FooterComponentFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; /** * The landing page that allows logging in for the user. @@ -34,11 +32,8 @@ public class LandingPageLayout extends DataManagerLayout implements RouterLayout private Button register; public LandingPageLayout(@Autowired LandingPageHandlerInterface handlerInterface, @Autowired - FooterComponentFactory footerComponentFactory, AnnouncementService announcementService, - @Value(value = "${announcement.initial-delay}") Duration initialDelay, - @Value(value = "${announcement.refresh-interval}") Duration refreshInterval) { - super(Objects.requireNonNull(footerComponentFactory), announcementService, initialDelay, - refreshInterval); + FooterComponentFactory footerComponentFactory, AnnouncementService announcementService) { + super(Objects.requireNonNull(footerComponentFactory), announcementService); Objects.requireNonNull(handlerInterface); addClassName("landing-page-layout"); //CSS class hosting the background image for all our landing pages diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/ProjectMainLayout.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/ProjectMainLayout.java index 3ad7efe06..0314e9c49 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/ProjectMainLayout.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/ProjectMainLayout.java @@ -11,7 +11,6 @@ import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.spring.security.AuthenticationContext; -import java.time.Duration; import life.qbic.datamanager.announcements.AnnouncementService; import life.qbic.datamanager.security.UserPermissions; import life.qbic.datamanager.views.Context; @@ -31,7 +30,6 @@ import life.qbic.projectmanagement.domain.model.experiment.ExperimentId; import life.qbic.projectmanagement.domain.model.project.ProjectId; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; /** * The ProjectMainLayout functions as a layout which contains all views related to managing a @@ -64,11 +62,8 @@ public ProjectMainLayout(@Autowired AuthenticationContext authenticationContext, @Autowired TerminologyService terminologyService, CancelConfirmationDialogFactory cancelConfirmationDialogFactory, MessageSourceNotificationFactory messageSourceNotificationFactory, - AnnouncementService announcementService, - @Value(value = "${announcement.initial-delay}") Duration initialDelay, - @Value(value = "${announcement.refresh-interval}") Duration refreshInterval) { - super(requireNonNull(footerComponentFactory), announcementService, initialDelay, - refreshInterval); + AnnouncementService announcementService) { + super(requireNonNull(footerComponentFactory), announcementService); requireNonNull(authenticationContext); requireNonNull(userInformationService); requireNonNull(projectInformationService); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/experiments/ExperimentMainLayout.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/experiments/ExperimentMainLayout.java index 41b2b6dd6..16bfa2999 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/experiments/ExperimentMainLayout.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/experiments/ExperimentMainLayout.java @@ -15,7 +15,6 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.RouteParam; import com.vaadin.flow.spring.security.AuthenticationContext; -import java.time.Duration; import java.util.List; import java.util.Optional; import life.qbic.application.commons.ApplicationException; @@ -40,7 +39,6 @@ import life.qbic.projectmanagement.domain.model.project.Project; import life.qbic.projectmanagement.domain.model.project.ProjectId; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; /** * The ExperimentMainLayout functions as a layout which contains all views related to managing @@ -75,11 +73,8 @@ public ExperimentMainLayout(@Autowired AuthenticationContext authenticationConte @Autowired TerminologyService terminologyService, CancelConfirmationDialogFactory cancelConfirmationDialogFactory, MessageSourceNotificationFactory messageSourceNotificationFactory, - AnnouncementService announcementService, - @Value(value = "${announcement.initial-delay}") Duration initialDelay, - @Value(value = "${announcement.refresh-interval}") Duration refreshInterval) { - super(requireNonNull(footerComponentFactory), announcementService, initialDelay, - refreshInterval); + AnnouncementService announcementService) { + super(requireNonNull(footerComponentFactory), announcementService); requireNonNull(authenticationContext); requireNonNull(userInformationService); requireNonNull(projectInformationService); From a77732d94b603e65f288677724a959dfb85132ab Mon Sep 17 00:00:00 2001 From: KochTobi Date: Mon, 17 Nov 2025 14:44:10 +0100 Subject: [PATCH 05/20] Clean up code --- .../announcements/AnnouncementComponent.java | 8 ++------ .../announcements/AnnouncementRepository.java | 10 +--------- .../datamanager/announcements/AnnouncementService.java | 4 ---- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementComponent.java index a5e2e7f79..f718b8c4d 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementComponent.java @@ -7,13 +7,10 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.icon.VaadinIcon; import life.qbic.datamanager.announcements.AnnouncementService.AnnouncementBundle; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import reactor.core.Disposable; public class AnnouncementComponent extends Div { - private static final Log log = LogFactory.getLog(AnnouncementComponent.class); private final transient AnnouncementService announcementService; @@ -27,9 +24,8 @@ public AnnouncementComponent(AnnouncementService announcementService) { private void refreshAnnouncements(AnnouncementBundle announcementBundle) { this.removeAll(); this.setVisible(!announcementBundle.isEmpty()); - announcementBundle.announcements().forEach(announcement -> { - add(renderAnnouncement(announcement)); - }); + announcementBundle.announcements().forEach( + announcement -> add(renderAnnouncement(announcement))); } private Component renderAnnouncement(AnnouncementService.Announcement announcement) { diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java index 5994bc877..e8abf87cb 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementRepository.java @@ -29,7 +29,7 @@ class Announcement { @Column(name = "message") private String message; - private Announcement() { + protected Announcement() { } protected Announcement(Long id, Instant displayStartTime, Instant displayEndTime, @@ -56,17 +56,9 @@ public Instant getDisplayStartTime() { return displayStartTime; } - public void setDisplayStartTime(Instant displayStartTime) { - this.displayStartTime = displayStartTime; - } - public Instant getDisplayEndTime() { return displayEndTime; } - - public void setDisplayEndTime(Instant displayEndTime) { - this.displayEndTime = displayEndTime; - } } List getAnnouncementByDisplayStartTimeBeforeAndDisplayEndTimeAfterOrderByDisplayStartTimeAsc( diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java index 8f2ddd071..4ee27b5f6 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/announcements/AnnouncementService.java @@ -36,10 +36,6 @@ record AnnouncementBundle(List announcements) { announcements = List.copyOf(announcements); } - static AnnouncementBundle empty() { - return new AnnouncementBundle(List.of()); - } - public boolean isEmpty() { return announcements.isEmpty(); } From 617844e1f7d3a25fa0235be89b225c2adea06d42 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Wed, 19 Nov 2025 09:33:30 +0100 Subject: [PATCH 06/20] Checkin progress --- fair-signposting/pom.xml | 32 + .../signposting/http/FormatException.java | 14 + .../datamanager/signposting/http/WebLink.java | 76 +++ .../signposting/http/WebLinkParser.java | 27 + .../signposting/http/WebLinkParserSpec.groovy | 548 ++++++++++++++++++ .../signposting/http/WebLinkSpec.groovy | 23 + pom.xml | 1 + 7 files changed, 721 insertions(+) create mode 100644 fair-signposting/pom.xml create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/FormatException.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java create mode 100644 fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy create mode 100644 fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkSpec.groovy diff --git a/fair-signposting/pom.xml b/fair-signposting/pom.xml new file mode 100644 index 000000000..0711ab1f9 --- /dev/null +++ b/fair-signposting/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + life.qbic.datamanager + datamanager + 1.11.0 + + + fair-signposting + + + 21 + 21 + UTF-8 + + + + + org.slf4j + slf4j-api + + + org.spockframework + spock-core + test + + + + diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/FormatException.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/FormatException.java new file mode 100644 index 000000000..4697628e9 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/FormatException.java @@ -0,0 +1,14 @@ +package life.qbic.datamanager.signposting.http; + +/** + * + * + *

+ * + * @since + */ +public final class FormatException extends RuntimeException { + public FormatException(String message) { + super(message); + } +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java new file mode 100644 index 000000000..459ed95aa --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java @@ -0,0 +1,76 @@ +package life.qbic.datamanager.signposting.http; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * A Java record representing a web link object following the + * RFC 8288 model specification. + * + * @author sven1103 + */ +public record WebLink(URI reference, Map> params) { + + /** + * Creates an RFC 8288 compliant web + * link object. + *

+ * Following RFC8288, the ABNF for a link parameter is: + *

+ * {@code link-param = token BWS [ "=" BWS ( token / quoted-string ) ]} + *

+ * The parameter key must not be empty, so during construction the {@code params} keys are checked + * for an empty key. The values can be empty though. + * + * @param reference a {@link URI} pointing to the actual resource + * @param params a {@link Map} of parameters as keys and a list of their values + * @return the new Weblink + * @throws FormatException if the parameters violate any known specification described in the RFC + * @throws NullPointerException if any method argument is {@code null} + */ + public static WebLink create(URI reference, Map> params) + throws FormatException, NullPointerException { + Objects.requireNonNull(reference); + Objects.requireNonNull(params); + if (hasEmptyParameterKey(params)) { + throw new FormatException("A parameter key must not be empty"); + } + return new WebLink(reference, params); + } + + /** + * Web link constructor that can be used if a web link has no parameters. + *

+ * See {@link WebLink#create(URI, Map)} for the full description. + * + * @param reference a {@link URI} pointing to the actual resource + * @return the new Weblink + * @throws FormatException if the parameters violate any known specification described in the RFC + * @throws NullPointerException if any method argument is {@code null} + */ + public static WebLink create(URI reference) throws FormatException, NullPointerException { + return create(reference, new HashMap<>()); + } + + /** + * Verifies the {@code token} has at least one character or more. + *

+ * See RFC 8288 and RFC 7230 section 3.2.6: + *

+ * {@code link-param = token BWS [ "=" BWS ( token / quoted-string ) ]} + *

+ * The parameter key must not be empty, so during construction the {@code params} keys are checked + * for an empty key. The values can be empty though. + * + * @param params the parameter map to check for an empty parameter key + * @return {@code true}, if an empty parameter key exists, else {@code false} + */ + private static boolean hasEmptyParameterKey(Map> params) { + return params.containsKey(""); + } + +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java new file mode 100644 index 000000000..5e898909d --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java @@ -0,0 +1,27 @@ +package life.qbic.datamanager.signposting.http; + +import java.util.Objects; + +/** + * Parses serialized information used in Web Linking as described in RFC 8288. + *

+ * The implementation is based on the Link Serialisation in HTTP Headers, section 3 of the + * RFC 8288. + * + * @author sven1103 + */ +public class WebLinkParser { + + private WebLinkParser() {} + + public static WebLinkParser create() { + return new WebLinkParser(); + } + + public WebLink parse(String link) throws NullPointerException, FormatException { + Objects.requireNonNull(link); + throw new RuntimeException("Not implemented yet"); + } + +} diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy new file mode 100644 index 000000000..85baee2ac --- /dev/null +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy @@ -0,0 +1,548 @@ +package life.qbic.datamanager.signposting.http + +import spock.lang.Specification + +class WebLinkParserSpec extends Specification { + + /** + * Why valid: link-value is < URI-Reference > with zero link-params. + * Spec: RFC 8288 Section 3 (“Link Serialisation in HTTP Headers”), ABNF link-value = "<" URI-Reference ">" *(...); * allows zero params. + */ + def "Minimal working serialized link, no parameters"() { + given: + var validSerialisation = "" + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: link-param is token BWS [ "=" BWS token ]; both rel and self are tokens. + * Spec: RFC 8288 Section 3; RFC 7230 section 3.2.6 defines token. + */ + def "Single parameter, token value"() { + given: + var validSerialisation = "; rel=self" + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: link-param value may be token / quoted-string; both forms equivalent. + * Spec: RFC 8288 section 3 (note on token vs quoted-string equivalence); RFC 7230 section 3.2.6 for quoted-string. + */ + def "Single parameter, quoted-string value"() { + given: + var validSerialisation = '; rel="self"' + } + + /** + * Why valid: ABNF allows zero or more ";" link-param after URI. + * Spec: RFC 8288 section 3, *( OWS ";" OWS link-param ). + */ + def "Multiple parameters"() { + given: + var validSerialisation = '; rel="self"; type="application/json"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: OWS and BWS allow optional whitespace around separators and =. + * Spec: RFC 8288 section 3 (uses OWS/BWS); RFC 7230 section 3.2.3 (OWS), section 3.2.4 (BWS concept). + */ + def "Whitespace around semi-colon and ="() { + given: + var validSerialisation = ' ; rel = "self" ; type = application/json' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: link-param = token BWS [ "=" BWS ( token / quoted-string ) ]; the [ ... ] part is optional, so no = is allowed. + * Spec: RFC 8288 section 3, link-param ABNF (optional value). + */ + def "Parameter without value"() { + given: + var validSerialisation = "; rel" + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: Empty string is a valid quoted-string. + * Spec: RFC 7230 section 3.2.6 (quoted-string can contain zero or more qdtext). + */ + def "Parameter with empty quoted string"() { + given: + var validSerialisation = '; title=""' + } + + /** + * Why valid: rel value is defined as a space-separated list of link relation types. + * Spec: RFC 8288 section 3.3 (“Relation Types”), which describes rel as a list of relation types. + */ + def "Multiple rel values in one parameter"() { + given: + var validSerialisation = '; rel="self describedby item"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: URI-Reference may be relative, resolved against base URI. + * Spec: RFC 8288 section 3 (uses URI-Reference); RFC 3986 section 4.1 (“URI Reference”). + */ + def "Relative URI"() { + given: + var validSerialisation = '; rel="item"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: At the header level, field-content is opaque to RFC 8288; title is a defined target attribute and its value is a quoted-string. + * Spec: RFC 8288 section 3 (defines title as a target attribute); RFC 7230 section 3.2 (header fields treat value as opaque except for defined syntax). + */ + def "Non-ASCII in quoted-string title"() { + given: + var validSerialisation = '; title="Données de recherche"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: link-value uses standard link-param names; rel="linkset" and type="application/linkset+json" are ordinary parameters. + * Spec: RFC 8288 section 3 (general link-param usage); linkset relation and media type from the Linkset draft (compatible with RFC 8288). + */ + def "Linkset type example"() { + given: + var validSerialisation = '; rel="linkset"; type="application/linkset+json"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: Link = #link-value; #rule allows 1+ link-values separated by commas in a single header field. + * Spec: RFC 8288 section 3 (Link = #link-value); RFC 7230 section 7 (“ABNF list extension: #rule”). + */ + def "Multiple link-values in one header"() { + given: + var validSerialisation = '; rel="self", ; rel="next"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: type parameter carries a media-type; application/ld+json fits token syntax and media-type grammar. + * Spec: RFC 8288 section 3 (defines type parameter); RFC 7231 section 3.1.1.1 (media-type grammar uses tokens). + */ + def "Parameter value as token with slash"() { + given: + var validSerialisation = '; type=application/ld+json' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: anchor is a registered link-parameter giving the context URI; its value is a quoted-string. + * Spec: RFC 8288 section 3.2 (“Target Attributes”) defines anchor; RFC 7230 section 3.2.6 for quoted-string. + */ + def "Anchor parameter"() { + given: + var validSerialisation = '; rel="self"; anchor="https://example.org/records/123"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why valid: link-param value may be token or quoted-string; mixing quoted and unquoted values is allowed. + * Spec: RFC 8288 section 3 (token / quoted-string equivalence for link-param values); RFC 7230 section 3.2.6. + */ + def "Mixed quoting styles in parameters"() { + given: + var validSerialisation = '; rel=self; type="application/json"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() + } + + /** + * Why invalid: link-value must start with "<" URI-Reference ">"; a bare URI with params does not match link-value syntax. + * Spec: RFC 8288 Section 3, link-value = "<" URI-Reference ">" *( ... ). + */ + def "Invalid: Missing angle brackets around URI"() { + given: + var invalidSerialisation = 'https://example.org/resource; rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: URI-Reference cannot be empty; "<>" has no URI between angle brackets. + * Spec: RFC 8288 Section 3 (URI-Reference); RFC 3986 section 4.1 (URI-reference = URI / relative-ref, neither is empty). + */ + def "Invalid: Empty URI reference"() { + given: + var invalidSerialisation = '<>; rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: link-value requires a "" prefix; parameters alone do not form a valid link-value. + * Spec: RFC 8288 Section 3, link-value ABNF. + */ + def "Invalid: Parameters without URI"() { + given: + var invalidSerialisation = 'rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + + } + + /** + * Why invalid: link-param must start with token; an empty name before equal sign violates token = 1*tchar. + * Spec: RFC 8288 section 3, link-param = token ...; RFC 7230 section 3.2.6 (token = 1*tchar). + */ + def "Invalid: Empty parameter name"() { + given: + var invalidSerialisation = '; =self' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: Each ";" must be followed by a link-param; ";;" introduces an empty parameter without a token. + * Spec: RFC 8288 section 3, *( OWS ";" OWS link-param ) requires a link-param after each ";". + */ + def "Invalid: Double semicolon introduces empty parameter"() { + given: + var invalidSerialisation = ';; rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: quoted-string must be closed; missing closing quote breaks quoted-string grammar. + * Spec: RFC 7230 section 3.2.6 (quoted-string ABNF). + */ + def "Invalid: Broken quoted-string without closing quote"() { + given: + var invalidSerialisation = '; rel="self' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: Comma is not allowed in token; parameter name containing "," violates token = 1*tchar. + * Spec: RFC 7230 section 3.2.6 (tchar set does not include ","). + */ + def "Invalid: Parameter name with illegal character"() { + given: + var invalidSerialisation = '; re,l="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid (strict header parsing): Comma is the list separator in #link-value; an unencoded comma inside the URI conflicts with list parsing. + * Spec: RFC 7230 section 7 (#rule uses "," as separator); RFC 3986 section 2.2 and section 2.4 (reserved chars like "," should be percent-encoded when they have special meaning). + */ + def "Invalid: Unencoded comma inside URI"() { + given: + var invalidSerialisation = '; rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: link-param requires a token before "="; "=" without a parameter name violates link-param syntax. + * Spec: RFC 8288 section 3, link-param = token BWS [ "=" ... ]; RFC 7230 section 3.2.6 (token required). + */ + def "Invalid: Parameter with only equals sign and no name"() { + given: + var invalidSerialisation = '; = "self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: link-value must start with ""; placing parameters before the URI does not match the ABNF. + * Spec: RFC 8288 section 3, link-value = "<" URI-Reference ">" *( ... ). + */ + def "Invalid: Parameters before URI"() { + given: + var invalidSerialisation = 'rel="self"; ' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: URI must be enclosed in "<" and ">"; bare URI with parameters is not a valid link-value. + * Spec: RFC 8288 section 3, "<" URI-Reference ">" is mandatory in link-value. + */ + def "Invalid: URI not enclosed in angle brackets"() { + given: + var invalidSerialisation = 'https://example.org/resource; rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid for strict media-type typing: type is defined as a media-type; stuffing "application/json; charset=utf-8" into one quoted-string is not a proper media-type value. + * Spec: RFC 8288 section 3 (type parameter uses media-type); RFC 7231 section 3.1.1.1 (media-type = type "/" subtype *( OWS ";" OWS parameter )). + */ + def "Invalid: Semicolon inside quoted type value treated as single media-type"() { + given: + var invalidSerialisation = '; type="application/json; charset=utf-8"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: link-value requires closing ">" around URI-Reference; missing ">" breaks "<" URI-Reference ">" pattern. + * Spec: RFC 8288 section 3, link-value ABNF. + */ + def "Invalid: Missing closing angle bracket"() { + given: + var invalidSerialisation = '" only OWS ";" OWS link-param is allowed; arbitrary token "foo" between ">" and ";" violates link-value syntax. + * Spec: RFC 8288 section 3, link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param ). + */ + def "Invalid: Garbage between URI and first parameter"() { + given: + var invalidSerialisation = ' foo ; rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: #link-value requires 1+ elements separated by commas; a leading comma introduces an empty element. + * Spec: RFC 8288 section 3 (Link = #link-value); RFC 7230 section 7 (#rule does not allow empty list elements). + */ + def "Invalid: Leading comma in Link header list"() { + given: + var invalidSerialisation = ', ; rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + + /** + * Why invalid: #link-value requires 1+ elements separated by commas; a trailing comma implies an empty last element. + * Spec: RFC 8288 section 3 (Link = #link-value); RFC 7230 section 7 (#rule does not allow empty list elements). + */ + def "Invalid: Trailing comma in Link header list"() { + given: + var invalidSerialisation = '; rel="self",' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + thrown(FormatException.class) + } + +} diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkSpec.groovy new file mode 100644 index 000000000..23ef8e972 --- /dev/null +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkSpec.groovy @@ -0,0 +1,23 @@ +package life.qbic.datamanager.signposting.http + +import spock.lang.Specification + +class WebLinkSpec extends Specification { + + def "An empty parameter key must throw an FormatException"() { + given: + var someURI = URI.create("myuri") + + and: + var someParameters = new HashMap>() + someParameters.put("someKey", "someValue") + someParameters.put("", "anotherValue") + + when: + WebLink.create(someURI, someParameters) + + then: + thrown(FormatException.class) + } + +} diff --git a/pom.xml b/pom.xml index 6d6d025ff..48d58588b 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ email-service-provider finances-infrastructure finances-api + fair-signposting pom From 91f78c517ad6053861fdbb9f5f0807f610aba18c Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Wed, 19 Nov 2025 09:40:27 +0100 Subject: [PATCH 07/20] Finish unit test template --- .../signposting/http/WebLinkParserSpec.groovy | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy index 85baee2ac..68400ef5b 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy @@ -47,6 +47,15 @@ class WebLinkParserSpec extends Specification { def "Single parameter, quoted-string value"() { given: var validSerialisation = '; rel="self"' + + and: + var weblinkParser = WebLinkParser.create() + + when: + weblinkParser.parse(validSerialisation) + + then: + noExceptionThrown() } /** @@ -268,7 +277,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -286,7 +295,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -304,7 +313,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -323,7 +332,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -341,7 +350,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -359,7 +368,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -377,7 +386,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -395,7 +404,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -413,7 +422,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -431,7 +440,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -449,7 +458,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -467,7 +476,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -485,7 +494,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -503,7 +512,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -521,7 +530,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) @@ -539,7 +548,7 @@ class WebLinkParserSpec extends Specification { var weblinkParser = WebLinkParser.create() when: - weblinkParser.parse(validSerialisation) + weblinkParser.parse(invalidSerialisation) then: thrown(FormatException.class) From a7e3de25acd966f1d71fa7ad6546d7dce8de03fe Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Thu, 20 Nov 2025 10:52:44 +0100 Subject: [PATCH 08/20] Implement URI parsing --- .../datamanager/signposting/http/WebLink.java | 2 - .../signposting/http/WebLinkParser.java | 30 +- .../http/lexer/SimpleWebLinkLexer.java | 197 +++++++++++++ .../signposting/http/lexer/WebLinkLexer.java | 20 ++ .../http/lexer/WebLinkLexingException.java | 16 ++ .../signposting/http/lexer/WebLinkToken.java | 24 ++ .../http/lexer/WebLinkTokenType.java | 53 ++++ .../signposting/http/parser/RawLink.java | 15 + .../http/parser/RawLinkHeader.java | 14 + .../signposting/http/parser/RawParam.java | 14 + .../http/parser/SimpleWebLinkParser.java | 153 ++++++++++ .../signposting/http/WebLinkParserSpec.groovy | 271 +++++++++++++----- 12 files changed, 715 insertions(+), 94 deletions(-) create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/SimpleWebLinkLexer.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexer.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexingException.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkToken.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkTokenType.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLinkHeader.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java index 459ed95aa..54e4f6de4 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java @@ -9,8 +9,6 @@ /** * A Java record representing a web link object following the * RFC 8288 model specification. - * - * @author sven1103 */ public record WebLink(URI reference, Map> params) { diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java index 5e898909d..3fe932038 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java @@ -1,27 +1,25 @@ package life.qbic.datamanager.signposting.http; -import java.util.Objects; +import java.util.List; +import life.qbic.datamanager.signposting.http.lexer.WebLinkToken; +import life.qbic.datamanager.signposting.http.parser.RawLinkHeader; /** - * Parses serialized information used in Web Linking as described in RFC 8288. - *

- * The implementation is based on the Link Serialisation in HTTP Headers, section 3 of the - * RFC 8288. + * * - * @author sven1103 + *

+ * + * @since */ -public class WebLinkParser { +public interface WebLinkParser { - private WebLinkParser() {} + RawLinkHeader parse(List tokens) throws NullPointerException, StructureException; - public static WebLinkParser create() { - return new WebLinkParser(); - } + class StructureException extends RuntimeException { - public WebLink parse(String link) throws NullPointerException, FormatException { - Objects.requireNonNull(link); - throw new RuntimeException("Not implemented yet"); - } + public StructureException(String message) { + super(message); + } + } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/SimpleWebLinkLexer.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/SimpleWebLinkLexer.java new file mode 100644 index 000000000..b2c554d15 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/SimpleWebLinkLexer.java @@ -0,0 +1,197 @@ +package life.qbic.datamanager.signposting.http.lexer; + +import java.util.ArrayList; +import java.util.List; + +/** + * Simple scanning lexer for RFC 8288 Web Link serialisations. + *

+ * This implementation: + *

    + *
  • Skips ASCII whitespace (OWS/BWS) between tokens
  • + *
  • Treats URIs as everything between "<" and ">"
  • + *
  • Treats unquoted tokens as IDENT
  • + *
  • Produces QUOTED tokens for quoted-string values (without the quotes)
  • + *
  • Emits an EOF token at the end of input
  • + *
+ *

+ * Parsing and semantic validation are handled by later stages. + */ +public final class SimpleWebLinkLexer implements WebLinkLexer { + + @Override + public List lex(String input) throws WebLinkLexingException { + return new Scanner(input).scan(); + } + + /** + * Internal scanner doing a single left-to-right pass over the input. + */ + private static final class Scanner { + + private final String input; + private final int length; + private int pos = 0; + + private final List tokens = new ArrayList<>(); + + Scanner(String input) { + this.input = input != null ? input : ""; + this.length = this.input.length(); + } + + List scan() { + while (!eof()) { + char c = peek(); + + if (isWhitespace(c)) { + consumeWhitespace(); + continue; + } + + int start = pos; + + switch (c) { + case '<' -> readUri(start); + case '>' -> { + advance(); + tokens.add(WebLinkToken.of(WebLinkTokenType.GT, ">", start)); + } + case ';' -> { + advance(); + tokens.add(WebLinkToken.of(WebLinkTokenType.SEMICOLON, ";", start)); + } + case '=' -> { + advance(); + tokens.add(WebLinkToken.of(WebLinkTokenType.EQUALS, "=", start)); + } + case ',' -> { + advance(); + tokens.add(WebLinkToken.of(WebLinkTokenType.COMMA, ",", start)); + } + case '"' -> readQuoted(start); + default -> readIdent(start); + } + } + + tokens.add(WebLinkToken.of(WebLinkTokenType.EOF, "", pos)); + return tokens; + } + + /** + * Reads a URI-Reference between "<" and ">". Emits three tokens: LT, URI, GT. + */ + private void readUri(int start) { + // consume "<" + advance(); + tokens.add(WebLinkToken.of(WebLinkTokenType.LT, "<", start)); + + int uriStart = pos; + + while (!eof()) { + char c = peek(); + if (c == '>') { + break; + } + advance(); + } + + if (eof()) { + throw new WebLinkLexingException( + "Unterminated URI reference: missing '>' for '<' at position " + start); + } + + String uriText = input.substring(uriStart, pos); + tokens.add(WebLinkToken.of(WebLinkTokenType.URI, uriText, uriStart)); + + // consume ">" + int gtPos = pos; + advance(); + tokens.add(WebLinkToken.of(WebLinkTokenType.GT, ">", gtPos)); + } + + /** + * Reads a quoted-string, without including the surrounding quotes. Does not yet handle escape + * sequences; that can be extended later. + */ + private void readQuoted(int start) { + // consume opening quote + advance(); + + int contentStart = pos; + + while (!eof()) { + char c = peek(); + if (c == '"') { + break; + } + // TODO: handle quoted-pair / escaping if needed + advance(); + } + + if (eof()) { + throw new WebLinkLexingException( + "Unterminated quoted-string starting at position " + start); + } + + String content = input.substring(contentStart, pos); + + // consume closing quote + advance(); + + tokens.add(WebLinkToken.of(WebLinkTokenType.QUOTED, content, contentStart)); + } + + /** + * Reads an unquoted token (IDENT) until a delimiter or whitespace is reached. + */ + private void readIdent(int start) { + while (!eof()) { + char c = peek(); + if (isDelimiter(c) || isWhitespace(c)) { + break; + } + advance(); + } + + String text = input.substring(start, pos); + if (!text.isEmpty()) { + tokens.add(WebLinkToken.of(WebLinkTokenType.IDENT, text, start)); + } + } + + private void consumeWhitespace() { + while (!eof() && isWhitespace(peek())) { + advance(); + } + } + + private boolean isWhitespace(char c) { + // OWS/BWS: space or horizontal tab are most important; + // here we also accept CR/LF defensively. + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; + } + + /** + * Characters that delimit IDENT tokens. + */ + private boolean isDelimiter(char c) { + return switch (c) { + case '<', '>', ';', '=', ',', '"' -> true; + default -> false; + }; + } + + private boolean eof() { + return pos >= length; + } + + private char peek() { + return input.charAt(pos); + } + + private void advance() { + pos++; + } + } +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexer.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexer.java new file mode 100644 index 000000000..a8978af3a --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexer.java @@ -0,0 +1,20 @@ +package life.qbic.datamanager.signposting.http.lexer; + +import java.util.List; + +/** + * Lexes a single Web Link (RFC 8288) serialisation string into a list of tokens. + *

+ * Implementations should be stateless or thread-confined. + */ +public interface WebLinkLexer { + + /** + * Lex the given input string into a sequence of tokens. + * + * @param input the raw Link header field-value or link-value + * @return list of tokens ending with an EOF token + * @throws WebLinkLexingException if the input is not lexically well-formed + */ + List lex(String input) throws WebLinkLexingException; +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexingException.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexingException.java new file mode 100644 index 000000000..9c10fa32a --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexingException.java @@ -0,0 +1,16 @@ +package life.qbic.datamanager.signposting.http.lexer; + + +/** + * Thrown when the input cannot be tokenised according to the Web Link lexical rules. + */ +public class WebLinkLexingException extends RuntimeException { + + public WebLinkLexingException(String message) { + super(message); + } + + public WebLinkLexingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkToken.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkToken.java new file mode 100644 index 000000000..0a542c72c --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkToken.java @@ -0,0 +1,24 @@ +package life.qbic.datamanager.signposting.http.lexer; + +/** + * Single token produced by a WebLinkLexer. + * + * @param type the token type + * @param text the raw text content for this token (without decorations like quotes) + * @param position the zero-based character offset in the input where this token starts + */ +public record WebLinkToken( + WebLinkTokenType type, + String text, + int position +) { + + public static WebLinkToken of(WebLinkTokenType type, String text, int position) { + return new WebLinkToken(type, text, position); + } + + @Override + public String toString() { + return type + "('" + text + "' @" + position + ")"; + } +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkTokenType.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkTokenType.java new file mode 100644 index 000000000..389301c6e --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkTokenType.java @@ -0,0 +1,53 @@ +package life.qbic.datamanager.signposting.http.lexer; + +/** + * Enumeration for being used to describe different token types for the + */ +public enum WebLinkTokenType { + + /** + * "<" + */ + LT, + + /** + * ">" + */ + GT, + + /** + * ";" + */ + SEMICOLON, + + /** + * "=" + */ + EQUALS, + + /** + * "," + */ + COMMA, + + /** + * A URI-Reference between "<" and ">". The angle brackets themselves are represented by LT and GT + * tokens. + */ + URI, + + /** + * An unquoted token (e.g. parameter name, token value). + */ + IDENT, + + /** + * A quoted-string value without the surrounding quotes. + */ + QUOTED, + + /** + * End-of-input marker. + */ + EOF +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java new file mode 100644 index 000000000..e95bcc720 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java @@ -0,0 +1,15 @@ +package life.qbic.datamanager.signposting.http.parser; + +import java.util.HashMap; +import java.util.Map; + +/** + * + * + *

+ * + * @since + */ +public record RawLink(String rawURI, Map rawWebLinks) { + +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLinkHeader.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLinkHeader.java new file mode 100644 index 000000000..40ce2d84e --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLinkHeader.java @@ -0,0 +1,14 @@ +package life.qbic.datamanager.signposting.http.parser; + +import java.util.List; + +/** + * + * + *

+ * + * @since + */ +public record RawLinkHeader(List rawLinks) { + +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java new file mode 100644 index 000000000..22cb95452 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java @@ -0,0 +1,14 @@ +package life.qbic.datamanager.signposting.http.parser; + +import java.util.List; + +/** + * + * + *

+ * + * @since + */ +public record RawParam(String name, List values) { + +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java new file mode 100644 index 000000000..f309446b9 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java @@ -0,0 +1,153 @@ +package life.qbic.datamanager.signposting.http.parser; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import life.qbic.datamanager.signposting.http.WebLinkParser; +import life.qbic.datamanager.signposting.http.lexer.WebLinkToken; +import life.qbic.datamanager.signposting.http.lexer.WebLinkTokenType; + +/** + * Parses serialized information used in Web Linking as described in RFC 8288. + *

+ * The implementation is based on the Link Serialisation in HTTP Headers, section 3 of the + * RFC 8288. + * + *

+ * + * Link = #link-value
link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param )
+ * link-param = token BWS [ "=" BWS ( token / quoted-string ) ] + *
+ * + */ +public class SimpleWebLinkParser implements WebLinkParser { + + private int currentPosition = 0; + + private List tokens; + + private SimpleWebLinkParser() { + } + + /** + * Creates a new SimpleWebLinkParser object instance. + * + * @return the new SimpleWebLinkParser + */ + public static SimpleWebLinkParser create() { + return new SimpleWebLinkParser(); + } + + + @Override + public RawLinkHeader parse(List tokens) + throws NullPointerException, StructureException { + Objects.requireNonNull(tokens); + if (tokens.isEmpty()) { + throw new StructureException( + "A link header entry must have at least one web link. Tokens were empty."); + } + + this.tokens = tokens.stream() + .sorted(Comparator.comparingInt(WebLinkToken::position)) + .toList(); + + ensureEOF("Lexer did not append EOF token"); + + // reset the current the parser state + currentPosition = 0; + + if (this.tokens.get(currentPosition).type() == WebLinkTokenType.EOF) { + throw new StructureException( + "A link header entry must have at least one web link. Tokens started with EOF."); + } + + var collectedLinks = new ArrayList(); + + while (this.tokens.get(currentPosition).type() != WebLinkTokenType.EOF) { + var parsedLink = parseLinkValue(); + if (parsedLink == null) { + throw new IllegalStateException("Should never happen"); + } + collectedLinks.add(parsedLink); + currentPosition++; + } + + return new RawLinkHeader(collectedLinks); + } + + private void ensureEOF(String errorMessage) throws IllegalStateException { + if (tokens.getLast().type() != WebLinkTokenType.EOF) { + throw new IllegalStateException(errorMessage); + } + } + + private RawLink parseLinkValue() { + var parsedLinkValue = parseUriReference(); + var parsedLinkParameters = parseParameters(); + return new RawLink(parsedLinkValue, parsedLinkParameters); + } + + private Map parseParameters() { + + return null; + } + + /** + * Checks the current token and throws an exception, if it is not of the expected type. + * + * @param token the expected token + * @throws StructureException if the current token does not match the expected one + */ + private void expectCurrent(WebLinkTokenType token) throws StructureException { + if (current().type() != token) { + throw new StructureException( + "Expected %s but found %s('%s') at position %d".formatted(token, current().type(), current().text(), current().position())); + } + } + + /** + * Will use the token from the current position with {@link this#current()} and try to parse the + * raw URI value. After successful return the current position is advanced to the next token in + * the list. + * + * @return the raw value of the URI + */ + private String parseUriReference() { + var uriValue = ""; + + // URI value must start with '<' + expectCurrent(WebLinkTokenType.LT); + next(); + + // URI reference expected + expectCurrent(WebLinkTokenType.URI); + uriValue = current().text(); + next(); + + // URI value must end with '>' + expectCurrent(WebLinkTokenType.GT); + + next(); + return uriValue; + } + + private WebLinkToken current() { + return tokens.get(currentPosition); + } + + private WebLinkToken next() { + if (currentPosition == tokens.size() - 1) { + return tokens.get(currentPosition); + } + return tokens.get(currentPosition++); + } + + private WebLinkToken peek() { + var nextPosition = currentPosition < tokens.size() - 1 ? currentPosition + 1 : currentPosition; + return tokens.get(nextPosition); + } +} diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy index 68400ef5b..de3d7e3d4 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy @@ -1,5 +1,7 @@ package life.qbic.datamanager.signposting.http +import life.qbic.datamanager.signposting.http.lexer.SimpleWebLinkLexer +import life.qbic.datamanager.signposting.http.parser.SimpleWebLinkParser import spock.lang.Specification class WebLinkParserSpec extends Specification { @@ -13,13 +15,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = "" and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -31,13 +37,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = "; rel=self" and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -49,13 +59,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; rel="self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -67,13 +81,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; rel="self"; type="application/json"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -85,13 +103,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = ' ; rel = "self" ; type = application/json' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -103,13 +125,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = "; rel" and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -119,6 +145,19 @@ class WebLinkParserSpec extends Specification { def "Parameter with empty quoted string"() { given: var validSerialisation = '; title=""' + + and: + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() + + when: + var result = weblinkParser.parse(lexer.lex(validSerialisation)) + + then: + noExceptionThrown() + result != null } /** @@ -130,13 +169,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; rel="self describedby item"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -148,13 +191,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; rel="item"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -166,13 +213,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; title="Données de recherche"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -184,13 +235,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; rel="linkset"; type="application/linkset+json"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -202,13 +257,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; rel="self", ; rel="next"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -220,13 +279,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; type=application/ld+json' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -238,13 +301,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; rel="self"; anchor="https://example.org/records/123"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -256,13 +323,17 @@ class WebLinkParserSpec extends Specification { var validSerialisation = '; rel=self; type="application/json"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(validSerialisation) + var result = weblinkParser.parse(lexer.lex(validSerialisation)) then: noExceptionThrown() + result != null } /** @@ -274,13 +345,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = 'https://example.org/resource; rel="self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -292,13 +366,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = '<>; rel="self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -310,13 +387,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = 'rel="self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } @@ -329,13 +409,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = '; =self' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -347,13 +430,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = ';; rel="self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -365,13 +451,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = '; rel="self' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -383,13 +472,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = '; re,l="self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -401,13 +493,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = '; rel="self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -419,13 +514,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = '; = "self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -437,13 +535,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = 'rel="self"; ' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -455,13 +556,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = 'https://example.org/resource; rel="self"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -473,13 +577,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = '; type="application/json; charset=utf-8"' and: - var weblinkParser = WebLinkParser.create() + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(invalidSerialisation) + weblinkParser.parse(lexer.lex(invalidSerialisation)) then: - thrown(FormatException.class) + thrown(WebLinkParser.StructureException.class) } /** @@ -491,13 +598,16 @@ class WebLinkParserSpec extends Specification { var invalidSerialisation = ' Date: Thu, 20 Nov 2025 16:11:14 +0100 Subject: [PATCH 09/20] Finalise parser --- .../signposting/http/parser/RawLink.java | 5 +- .../signposting/http/parser/RawParam.java | 12 +- .../http/parser/SimpleWebLinkParser.java | 103 +++++++++++-- .../signposting/http/WebLinkParserSpec.groovy | 135 ++++++------------ 4 files changed, 145 insertions(+), 110 deletions(-) diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java index e95bcc720..474dbe6a0 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java @@ -1,7 +1,6 @@ package life.qbic.datamanager.signposting.http.parser; -import java.util.HashMap; -import java.util.Map; +import java.util.List; /** * @@ -10,6 +9,6 @@ * * @since */ -public record RawLink(String rawURI, Map rawWebLinks) { +public record RawLink(String rawURI, List rawParameters) { } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java index 22cb95452..a0a99819a 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java @@ -1,7 +1,5 @@ package life.qbic.datamanager.signposting.http.parser; -import java.util.List; - /** * * @@ -9,6 +7,14 @@ * * @since */ -public record RawParam(String name, List values) { +public record RawParam(String name, String value) { + + public static RawParam emptyParameter(String name) { + return new RawParam(name, ""); + } + + public static RawParam withValue(String name, String value) { + return new RawParam(name, value); + } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java index f309446b9..bf8995162 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java @@ -1,11 +1,14 @@ package life.qbic.datamanager.signposting.http.parser; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import life.qbic.datamanager.signposting.http.WebLinkParser; +import life.qbic.datamanager.signposting.http.WebLinkParser.StructureException; import life.qbic.datamanager.signposting.http.lexer.WebLinkToken; import life.qbic.datamanager.signposting.http.lexer.WebLinkTokenType; @@ -67,15 +70,20 @@ public RawLinkHeader parse(List tokens) var collectedLinks = new ArrayList(); - while (this.tokens.get(currentPosition).type() != WebLinkTokenType.EOF) { - var parsedLink = parseLinkValue(); - if (parsedLink == null) { - throw new IllegalStateException("Should never happen"); + var parsedLink = parseLinkValue(); + collectedLinks.add(parsedLink); + // While there is ',' (COMMA) present, parse another link value + while (current().type() == WebLinkTokenType.COMMA) { + next(); + if (currentIsEof()) { + throw new StructureException("Unexpected trailing comma: expected another link-value after ','."); } - collectedLinks.add(parsedLink); - currentPosition++; + collectedLinks.add(parseLinkValue()); } + // Last consumed token must be always EOF to ensure that the token stream has been consumed + expectCurrent(WebLinkTokenType.EOF); + return new RawLinkHeader(collectedLinks); } @@ -87,13 +95,64 @@ private void ensureEOF(String errorMessage) throws IllegalStateException { private RawLink parseLinkValue() { var parsedLinkValue = parseUriReference(); - var parsedLinkParameters = parseParameters(); - return new RawLink(parsedLinkValue, parsedLinkParameters); + if (current().type() != WebLinkTokenType.COMMA) { + return new RawLink(parsedLinkValue, parseParameters()); + } + return new RawLink(parsedLinkValue, List.of()); + } + + private List parseParameters() { + var parameters = new ArrayList(); + if (currentIsEof()) { + return parameters; + } + // expected separator for a parameter entry is ';' (semicolon) based on RFC 8288 section 3 + expectCurrent(WebLinkTokenType.SEMICOLON); + next(); + + // now one or more parameters can follow + while(current().type() != WebLinkTokenType.COMMA) { + RawParam parameter = parseParameter(); + parameters.add(parameter); + // If the current token is no ';' (SEMICOLON), no additional parameters are expected + if (current().type() != WebLinkTokenType.SEMICOLON) { + break; + } + next(); + } + return parameters; } - private Map parseParameters() { + private RawParam parseParameter() throws StructureException { + expectCurrent(WebLinkTokenType.IDENT); + var paramName = current().text(); + + next(); + + // Checks for empty parameter + if (currentIsEof() + || current().type() == WebLinkTokenType.COMMA + || current().type() == WebLinkTokenType.SEMICOLON + ) { + return RawParam.emptyParameter(paramName); + } + + // Next token must be "=" (equals) + // RFC 8288: token BWS [ "=" BWS (token / quoted-string ) ] + expectCurrent(WebLinkTokenType.EQUALS); + + next(); + + expectCurrentAny(WebLinkTokenType.IDENT, WebLinkTokenType.QUOTED); + var rawParamValue = current().text(); + + next(); + + return RawParam.withValue(paramName, rawParamValue); + } - return null; + private boolean currentIsEof() { + return current().type() == WebLinkTokenType.EOF; } /** @@ -105,7 +164,23 @@ private Map parseParameters() { private void expectCurrent(WebLinkTokenType token) throws StructureException { if (current().type() != token) { throw new StructureException( - "Expected %s but found %s('%s') at position %d".formatted(token, current().type(), current().text(), current().position())); + "Expected %s but found %s('%s') at position %d".formatted(token, current().type(), + current().text(), current().position())); + } + } + + private void expectCurrentAny(WebLinkTokenType... expected) throws StructureException { + var matches = Arrays.stream(expected) + .anyMatch(type -> type.equals(current().type())); + + if (!matches) { + var expectedNames = Arrays.stream(expected) + .map(Enum::name) + .reduce((a, b) -> a + ", " + b) + .orElse(""); + throw new StructureException( + "Expected any of [%s] but found %s('%s') at position %d" + .formatted(expectedNames, current().type(), current().text(), current().position())); } } @@ -140,10 +215,10 @@ private WebLinkToken current() { } private WebLinkToken next() { - if (currentPosition == tokens.size() - 1) { - return tokens.get(currentPosition); + if (currentPosition < tokens.size() - 1) { + currentPosition++; } - return tokens.get(currentPosition++); + return current(); } private WebLinkToken peek() { diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy index de3d7e3d4..b7af9c25b 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy @@ -270,6 +270,24 @@ class WebLinkParserSpec extends Specification { result != null } + def "Multiple links without parameters"() { + given: + var validSerialisation = ', ' + + and: + var weblinkParser = SimpleWebLinkParser.create() + + and: + var lexer = new SimpleWebLinkLexer() + + when: + var result = weblinkParser.parse(lexer.lex(validSerialisation)) + + then: + noExceptionThrown() + result != null + } + /** * Why valid: type parameter carries a media-type; application/ld+json fits token syntax and media-type grammar. * Spec: RFC 8288 section 3 (defines type parameter); RFC 7231 section 3.1.1.1 (media-type grammar uses tokens). @@ -337,12 +355,12 @@ class WebLinkParserSpec extends Specification { } /** - * Why invalid: link-value must start with "<" URI-Reference ">"; a bare URI with params does not match link-value syntax. - * Spec: RFC 8288 Section 3, link-value = "<" URI-Reference ">" *( ... ). + * Why invalid: A trailing comma indicates an empty link value, which is invalid. + * Spec: RFC 8288 Section 3, link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param )” */ - def "Invalid: Missing angle brackets around URI"() { + def "No trailing comma allowed for multiple link values"() { given: - var invalidSerialisation = 'https://example.org/resource; rel="self"' + var validSerialisation = ',' and: var weblinkParser = SimpleWebLinkParser.create() @@ -351,19 +369,16 @@ class WebLinkParserSpec extends Specification { var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(lexer.lex(invalidSerialisation)) + weblinkParser.parse(lexer.lex(validSerialisation)) then: thrown(WebLinkParser.StructureException.class) } - /** - * Why invalid: URI-Reference cannot be empty; "<>" has no URI between angle brackets. - * Spec: RFC 8288 Section 3 (URI-Reference); RFC 3986 section 4.1 (URI-reference = URI / relative-ref, neither is empty). - */ - def "Invalid: Empty URI reference"() { + def "No trailing semicolon allowed for multiple link values"() { + given: - var invalidSerialisation = '<>; rel="self"' + var validSerialisation = ';' and: var weblinkParser = SimpleWebLinkParser.create() @@ -372,19 +387,20 @@ class WebLinkParserSpec extends Specification { var lexer = new SimpleWebLinkLexer() when: - weblinkParser.parse(lexer.lex(invalidSerialisation)) + weblinkParser.parse(lexer.lex(validSerialisation)) then: thrown(WebLinkParser.StructureException.class) } + /** - * Why invalid: link-value requires a "" prefix; parameters alone do not form a valid link-value. - * Spec: RFC 8288 Section 3, link-value ABNF. + * Why invalid: link-value must start with "<" URI-Reference ">"; a bare URI with params does not match link-value syntax. + * Spec: RFC 8288 Section 3, link-value = "<" URI-Reference ">" *( ... ). */ - def "Invalid: Parameters without URI"() { + def "Invalid: Missing angle brackets around URI"() { given: - var invalidSerialisation = 'rel="self"' + var invalidSerialisation = 'https://example.org/resource; rel="self"' and: var weblinkParser = SimpleWebLinkParser.create() @@ -397,16 +413,15 @@ class WebLinkParserSpec extends Specification { then: thrown(WebLinkParser.StructureException.class) - } /** - * Why invalid: link-param must start with token; an empty name before equal sign violates token = 1*tchar. - * Spec: RFC 8288 section 3, link-param = token ...; RFC 7230 section 3.2.6 (token = 1*tchar). + * Why invalid: link-value requires a "" prefix; parameters alone do not form a valid link-value. + * Spec: RFC 8288 Section 3, link-value ABNF. */ - def "Invalid: Empty parameter name"() { + def "Invalid: Parameters without URI"() { given: - var invalidSerialisation = '; =self' + var invalidSerialisation = 'rel="self"' and: var weblinkParser = SimpleWebLinkParser.create() @@ -419,15 +434,16 @@ class WebLinkParserSpec extends Specification { then: thrown(WebLinkParser.StructureException.class) + } /** - * Why invalid: Each ";" must be followed by a link-param; ";;" introduces an empty parameter without a token. - * Spec: RFC 8288 section 3, *( OWS ";" OWS link-param ) requires a link-param after each ";". + * Why invalid: link-param must start with token; an empty name before equal sign violates token = 1*tchar. + * Spec: RFC 8288 section 3, link-param = token ...; RFC 7230 section 3.2.6 (token = 1*tchar). */ - def "Invalid: Double semicolon introduces empty parameter"() { + def "Invalid: Empty parameter name"() { given: - var invalidSerialisation = ';; rel="self"' + var invalidSerialisation = '; =self' and: var weblinkParser = SimpleWebLinkParser.create() @@ -443,12 +459,12 @@ class WebLinkParserSpec extends Specification { } /** - * Why invalid: quoted-string must be closed; missing closing quote breaks quoted-string grammar. - * Spec: RFC 7230 section 3.2.6 (quoted-string ABNF). + * Why invalid: Each ";" must be followed by a link-param; ";;" introduces an empty parameter without a token. + * Spec: RFC 8288 section 3, *( OWS ";" OWS link-param ) requires a link-param after each ";". */ - def "Invalid: Broken quoted-string without closing quote"() { + def "Invalid: Double semicolon introduces empty parameter"() { given: - var invalidSerialisation = '; rel="self' + var invalidSerialisation = ';; rel="self"' and: var weblinkParser = SimpleWebLinkParser.create() @@ -484,26 +500,6 @@ class WebLinkParserSpec extends Specification { thrown(WebLinkParser.StructureException.class) } - /** - * Why invalid (strict header parsing): Comma is the list separator in #link-value; an unencoded comma inside the URI conflicts with list parsing. - * Spec: RFC 7230 section 7 (#rule uses "," as separator); RFC 3986 section 2.2 and section 2.4 (reserved chars like "," should be percent-encoded when they have special meaning). - */ - def "Invalid: Unencoded comma inside URI"() { - given: - var invalidSerialisation = '; rel="self"' - - and: - var weblinkParser = SimpleWebLinkParser.create() - - and: - var lexer = new SimpleWebLinkLexer() - - when: - weblinkParser.parse(lexer.lex(invalidSerialisation)) - - then: - thrown(WebLinkParser.StructureException.class) - } /** * Why invalid: link-param requires a token before "="; "=" without a parameter name violates link-param syntax. @@ -568,47 +564,6 @@ class WebLinkParserSpec extends Specification { thrown(WebLinkParser.StructureException.class) } - /** - * Why invalid for strict media-type typing: type is defined as a media-type; stuffing "application/json; charset=utf-8" into one quoted-string is not a proper media-type value. - * Spec: RFC 8288 section 3 (type parameter uses media-type); RFC 7231 section 3.1.1.1 (media-type = type "/" subtype *( OWS ";" OWS parameter )). - */ - def "Invalid: Semicolon inside quoted type value treated as single media-type"() { - given: - var invalidSerialisation = '; type="application/json; charset=utf-8"' - - and: - var weblinkParser = SimpleWebLinkParser.create() - - and: - var lexer = new SimpleWebLinkLexer() - - when: - weblinkParser.parse(lexer.lex(invalidSerialisation)) - - then: - thrown(WebLinkParser.StructureException.class) - } - - /** - * Why invalid: link-value requires closing ">" around URI-Reference; missing ">" breaks "<" URI-Reference ">" pattern. - * Spec: RFC 8288 section 3, link-value ABNF. - */ - def "Invalid: Missing closing angle bracket"() { - given: - var invalidSerialisation = '" only OWS ";" OWS link-param is allowed; arbitrary token "foo" between ">" and ";" violates link-value syntax. From 5184a89d33e39183f4f66f6803a598a0047ea685 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 21 Nov 2025 09:53:50 +0100 Subject: [PATCH 10/20] Provide java docs --- .../http/parser/SimpleWebLinkParser.java | 108 +++++++++++++++--- 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java index bf8995162..c845d26fa 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java @@ -3,12 +3,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import life.qbic.datamanager.signposting.http.WebLinkParser; -import life.qbic.datamanager.signposting.http.WebLinkParser.StructureException; import life.qbic.datamanager.signposting.http.lexer.WebLinkToken; import life.qbic.datamanager.signposting.http.lexer.WebLinkTokenType; @@ -45,24 +42,48 @@ public static SimpleWebLinkParser create() { } + /** + * Parses a list of lexed web link tokens to a raw link header value. The parser only performs + * structural validation, not semantic validation. + *

+ * The template for structural validation is the serialisation description in ABNF for RFC 8288 + * Section 3. + * + *

+ * Parser contract: + * + *

    + *
  • The token list must contain an EOF token
  • + *
  • The last token item must be an EOF token, based on ascending sorting by position
  • + *
+ *

+ * In case the contract is violated, a structure exception is thrown. + * + * @param tokens a list of tokens to parse as raw web link header + * @return a raw web link header, structurally validated against RFC 8288 + * @throws NullPointerException if the tokens list is {@code null} + * @throws StructureException if the tokens violate the structure of a valid web link token + */ @Override public RawLinkHeader parse(List tokens) throws NullPointerException, StructureException { Objects.requireNonNull(tokens); + if (tokens.isEmpty()) { throw new StructureException( "A link header entry must have at least one web link. Tokens were empty."); } + // Always reset the internal state on every parse() call + reset(); + this.tokens = tokens.stream() .sorted(Comparator.comparingInt(WebLinkToken::position)) .toList(); + // Validate contract ensureEOF("Lexer did not append EOF token"); - // reset the current the parser state - currentPosition = 0; - if (this.tokens.get(currentPosition).type() == WebLinkTokenType.EOF) { throw new StructureException( "A link header entry must have at least one web link. Tokens started with EOF."); @@ -76,7 +97,8 @@ public RawLinkHeader parse(List tokens) while (current().type() == WebLinkTokenType.COMMA) { next(); if (currentIsEof()) { - throw new StructureException("Unexpected trailing comma: expected another link-value after ','."); + throw new StructureException( + "Unexpected trailing comma: expected another link-value after ','."); } collectedLinks.add(parseLinkValue()); } @@ -87,12 +109,39 @@ public RawLinkHeader parse(List tokens) return new RawLinkHeader(collectedLinks); } + /** + * Resets the internal state of the parser instance + */ + private void reset() { + currentPosition = 0; + } + + /** + * Checks if the last token in the token list is an EOF token. To keep the parser robust and + * simple, this is part of the contract and the parser shall fail early if the contract is + * violated. + * + * @param errorMessage the message to provide in the exception + * @throws IllegalStateException if the last token of the list ist not an EOF token + */ private void ensureEOF(String errorMessage) throws IllegalStateException { if (tokens.getLast().type() != WebLinkTokenType.EOF) { throw new IllegalStateException(errorMessage); } } + /** + * Parses a single web link value, which must contain a target (URI). Optionally, the web link can + * have one or more parameters. + *

+ * If the target has a trailing ',' (COMMA), no further parameters are expected. + *

+ * The correctness of the parameter structure with a precedent ';' (SEMICOLON) after the target is + * concern of the {@link #parseParameters()} method, since it is part of the parameter list + * description. + * + * @return a raw web link value with target and optionally one or more parameters + */ private RawLink parseLinkValue() { var parsedLinkValue = parseUriReference(); if (current().type() != WebLinkTokenType.COMMA) { @@ -101,6 +150,18 @@ private RawLink parseLinkValue() { return new RawLink(parsedLinkValue, List.of()); } + /** + * Parses parameters beginning from the current token position (inclusive). + *

+ * Based on the serialisation description of RFC 8288 for link-values, params must have a + * precedent ';' (SEMICOLON). If the start position on method call is not a semicolon, an + * exception will be thrown. + *

+ * In case the link-value has no parameters at all (e.g. multiple web links with targets (URI) + * only), this method should not be called in the first place. + * + * @return a list of raw parameters with param name and value + */ private List parseParameters() { var parameters = new ArrayList(); if (currentIsEof()) { @@ -111,7 +172,7 @@ private List parseParameters() { next(); // now one or more parameters can follow - while(current().type() != WebLinkTokenType.COMMA) { + while (current().type() != WebLinkTokenType.COMMA) { RawParam parameter = parseParameter(); parameters.add(parameter); // If the current token is no ';' (SEMICOLON), no additional parameters are expected @@ -151,6 +212,11 @@ private RawParam parseParameter() throws StructureException { return RawParam.withValue(paramName, rawParamValue); } + /** + * Evaluates if the current token is an EOF token. + * + * @return {@code true}, if the current token is an EOF token, else {@code false} + */ private boolean currentIsEof() { return current().type() == WebLinkTokenType.EOF; } @@ -169,6 +235,15 @@ private void expectCurrent(WebLinkTokenType token) throws StructureException { } } + /** + * Checks if the current token matches any (at least one) expected token. + *

+ * If no expected type is provided, the method will throw a + * {@link life.qbic.datamanager.signposting.http.WebLinkParser.StructureException}. + * + * @param expected zero or more expected token types. + * @throws StructureException if the current token does not match any expected token + */ private void expectCurrentAny(WebLinkTokenType... expected) throws StructureException { var matches = Arrays.stream(expected) .anyMatch(type -> type.equals(current().type())); @@ -210,19 +285,26 @@ private String parseUriReference() { return uriValue; } + /** + * Returns the token on the current position. + * + * @return the token on the current position. + */ private WebLinkToken current() { return tokens.get(currentPosition); } + /** + * Returns the next token from the current position. If the current position is already the last + * token of the token list, the last token will be returned. + *

+ * By contract, the parser expects the last item to be an EOF token (see + * {@link WebLinkTokenType#EOF}). So the last item in the token list will always be an EOF token. + */ private WebLinkToken next() { if (currentPosition < tokens.size() - 1) { currentPosition++; } return current(); } - - private WebLinkToken peek() { - var nextPosition = currentPosition < tokens.size() - 1 ? currentPosition + 1 : currentPosition; - return tokens.get(nextPosition); - } } From 2b63ed05ae87fcb0eff046461377d624a7a0e9be Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 21 Nov 2025 12:32:21 +0100 Subject: [PATCH 11/20] Rename --- .../signposting/http/Validator.java | 52 +++++++++++++++++++ .../http/{lexer => }/WebLinkLexer.java | 4 +- .../signposting/http/WebLinkParser.java | 4 +- .../{lexer => lexing}/SimpleWebLinkLexer.java | 3 +- .../WebLinkLexingException.java | 2 +- .../http/{lexer => lexing}/WebLinkToken.java | 2 +- .../{lexer => lexing}/WebLinkTokenType.java | 2 +- .../http/{parser => parsing}/RawLink.java | 2 +- .../{parser => parsing}/RawLinkHeader.java | 2 +- .../http/{parser => parsing}/RawParam.java | 2 +- .../SimpleWebLinkParser.java | 6 +-- .../signposting/http/WebLinkParserSpec.groovy | 4 +- 12 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{lexer => }/WebLinkLexer.java (74%) rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{lexer => lexing}/SimpleWebLinkLexer.java (97%) rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{lexer => lexing}/WebLinkLexingException.java (85%) rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{lexer => lexing}/WebLinkToken.java (91%) rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{lexer => lexing}/WebLinkTokenType.java (91%) rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{parser => parsing}/RawLink.java (82%) rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{parser => parsing}/RawLinkHeader.java (81%) rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{parser => parsing}/RawParam.java (88%) rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{parser => parsing}/SimpleWebLinkParser.java (98%) diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java new file mode 100644 index 000000000..9c8216f18 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java @@ -0,0 +1,52 @@ +package life.qbic.datamanager.signposting.http; + +import java.util.List; +import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader; + +/** + * + * + * @since + */ +public interface Validator { + + ValidationResult validate(RawLinkHeader rawLinkHeader); + + record ValidationResult(List weblinks, IssueReport report) { + + boolean containsIssues() { + return !report.isEmpty(); + } + } + + record IssueReport(List issues) { + + boolean hasErrors() { + return issues.stream().anyMatch(Issue::isError); + } + + boolean hasWarnings() { + return issues.stream().anyMatch(Issue::isWarning); + } + + boolean isEmpty() { + return issues.isEmpty(); + } + } + + record Issue(String message, IssueType type) { + + boolean isWarning() { + return type.equals(IssueType.WARNING); + } + + boolean isError() { + return type.equals(IssueType.ERROR); + } + } + + enum IssueType { + WARNING, + ERROR + } +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexer.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java similarity index 74% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexer.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java index a8978af3a..3a1a7ac14 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexer.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java @@ -1,6 +1,8 @@ -package life.qbic.datamanager.signposting.http.lexer; +package life.qbic.datamanager.signposting.http; import java.util.List; +import life.qbic.datamanager.signposting.http.lexing.WebLinkLexingException; +import life.qbic.datamanager.signposting.http.lexing.WebLinkToken; /** * Lexes a single Web Link (RFC 8288) serialisation string into a list of tokens. diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java index 3fe932038..f462d8364 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java @@ -1,8 +1,8 @@ package life.qbic.datamanager.signposting.http; import java.util.List; -import life.qbic.datamanager.signposting.http.lexer.WebLinkToken; -import life.qbic.datamanager.signposting.http.parser.RawLinkHeader; +import life.qbic.datamanager.signposting.http.lexing.WebLinkToken; +import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader; /** * diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/SimpleWebLinkLexer.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java similarity index 97% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/SimpleWebLinkLexer.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java index b2c554d15..192dbb6f1 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/SimpleWebLinkLexer.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java @@ -1,7 +1,8 @@ -package life.qbic.datamanager.signposting.http.lexer; +package life.qbic.datamanager.signposting.http.lexing; import java.util.ArrayList; import java.util.List; +import life.qbic.datamanager.signposting.http.WebLinkLexer; /** * Simple scanning lexer for RFC 8288 Web Link serialisations. diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexingException.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkLexingException.java similarity index 85% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexingException.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkLexingException.java index 9c10fa32a..82db7c6a4 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkLexingException.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkLexingException.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.signposting.http.lexer; +package life.qbic.datamanager.signposting.http.lexing; /** diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkToken.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkToken.java similarity index 91% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkToken.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkToken.java index 0a542c72c..3de6b18e5 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkToken.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkToken.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.signposting.http.lexer; +package life.qbic.datamanager.signposting.http.lexing; /** * Single token produced by a WebLinkLexer. diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkTokenType.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkTokenType.java similarity index 91% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkTokenType.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkTokenType.java index 389301c6e..ffc1ff996 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexer/WebLinkTokenType.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkTokenType.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.signposting.http.lexer; +package life.qbic.datamanager.signposting.http.lexing; /** * Enumeration for being used to describe different token types for the diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawLink.java similarity index 82% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawLink.java index 474dbe6a0..7d9ff7ddb 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLink.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawLink.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.signposting.http.parser; +package life.qbic.datamanager.signposting.http.parsing; import java.util.List; diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLinkHeader.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawLinkHeader.java similarity index 81% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLinkHeader.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawLinkHeader.java index 40ce2d84e..fa5036fb5 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawLinkHeader.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawLinkHeader.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.signposting.http.parser; +package life.qbic.datamanager.signposting.http.parsing; import java.util.List; diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java similarity index 88% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java index a0a99819a..7503ed9d7 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/RawParam.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.signposting.http.parser; +package life.qbic.datamanager.signposting.http.parsing; /** * diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java similarity index 98% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java index c845d26fa..5a3126145 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parser/SimpleWebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.signposting.http.parser; +package life.qbic.datamanager.signposting.http.parsing; import java.util.ArrayList; import java.util.Arrays; @@ -6,8 +6,8 @@ import java.util.List; import java.util.Objects; import life.qbic.datamanager.signposting.http.WebLinkParser; -import life.qbic.datamanager.signposting.http.lexer.WebLinkToken; -import life.qbic.datamanager.signposting.http.lexer.WebLinkTokenType; +import life.qbic.datamanager.signposting.http.lexing.WebLinkToken; +import life.qbic.datamanager.signposting.http.lexing.WebLinkTokenType; /** * Parses serialized information used in Web Linking as described in Date: Fri, 21 Nov 2025 15:43:04 +0100 Subject: [PATCH 12/20] Finish simple validator --- .../signposting/http/Validator.java | 20 ++- .../datamanager/signposting/http/WebLink.java | 8 +- .../http/validation/Rfc8288Validator.java | 85 +++++++++ .../{ => parsing}/WebLinkParserSpec.groovy | 4 +- .../validation/Rfc8288ValidatorSpec.groovy | 168 ++++++++++++++++++ 5 files changed, 273 insertions(+), 12 deletions(-) create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java rename fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/{ => parsing}/WebLinkParserSpec.groovy (99%) create mode 100644 fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java index 9c8216f18..f50e3b35a 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java @@ -14,33 +14,41 @@ public interface Validator { record ValidationResult(List weblinks, IssueReport report) { - boolean containsIssues() { + public boolean containsIssues() { return !report.isEmpty(); } } record IssueReport(List issues) { - boolean hasErrors() { + public boolean hasErrors() { return issues.stream().anyMatch(Issue::isError); } - boolean hasWarnings() { + public boolean hasWarnings() { return issues.stream().anyMatch(Issue::isWarning); } - boolean isEmpty() { + public boolean isEmpty() { return issues.isEmpty(); } } record Issue(String message, IssueType type) { - boolean isWarning() { + public static Issue warning(String message) { + return new Issue(message, IssueType.WARNING); + } + + public static Issue error(String message) { + return new Issue(message, IssueType.ERROR); + } + + public boolean isWarning() { return type.equals(IssueType.WARNING); } - boolean isError() { + public boolean isError() { return type.equals(IssueType.ERROR); } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java index 54e4f6de4..d55ccb933 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java @@ -2,15 +2,15 @@ import java.net.URI; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; /** * A Java record representing a web link object following the * RFC 8288 model specification. */ -public record WebLink(URI reference, Map> params) { +public record WebLink(URI reference, Map> params) { /** * Creates an RFC 8288 compliant web @@ -29,7 +29,7 @@ public record WebLink(URI reference, Map> params) { * @throws FormatException if the parameters violate any known specification described in the RFC * @throws NullPointerException if any method argument is {@code null} */ - public static WebLink create(URI reference, Map> params) + public static WebLink create(URI reference, Map> params) throws FormatException, NullPointerException { Objects.requireNonNull(reference); Objects.requireNonNull(params); @@ -67,7 +67,7 @@ public static WebLink create(URI reference) throws FormatException, NullPointerE * @param params the parameter map to check for an empty parameter key * @return {@code true}, if an empty parameter key exists, else {@code false} */ - private static boolean hasEmptyParameterKey(Map> params) { + private static boolean hasEmptyParameterKey(Map> params) { return params.containsKey(""); } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java new file mode 100644 index 000000000..457d52edc --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java @@ -0,0 +1,85 @@ +package life.qbic.datamanager.signposting.http.validation; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import life.qbic.datamanager.signposting.http.Validator; +import life.qbic.datamanager.signposting.http.WebLink; +import life.qbic.datamanager.signposting.http.parsing.RawLink; +import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader; +import life.qbic.datamanager.signposting.http.parsing.RawParam; + +/** + * + * + * @since + */ +public class Rfc8288Validator implements Validator { + + // Defined in https://www.rfc-editor.org/rfc/rfc7230, section 3.2.6 + private static final Pattern ALLOWED_TOKEN_CHARS = Pattern.compile( + "^[!#$%&'*+-.^_`|~0-9A-Za-z]+$"); + + @Override + public ValidationResult validate(RawLinkHeader rawLinkHeader) { + var recordedIssues = new ArrayList(); + + var webLinks = new ArrayList(); + for (RawLink rawLink : rawLinkHeader.rawLinks()) { + var webLink = validate(rawLink, recordedIssues); + if (webLink != null) { + webLinks.add(webLink); + } + } + return new ValidationResult(webLinks, new IssueReport(List.copyOf(recordedIssues))); + } + + private WebLink validate(RawLink rawLink, List recordedIssues) { + URI uri = null; + try { + uri = URI.create(rawLink.rawURI()); + } catch (IllegalArgumentException e) { + recordedIssues.add( + Issue.error("Invalid URI '%s': %s".formatted(rawLink.rawURI(), e.getMessage()))); + } + var parameters = validateParams(rawLink.rawParameters(), recordedIssues); + + if (uri == null) { + return null; + } + return new WebLink(uri, parameters); + } + + private Map> validateParams( + List rawParams, List recordedIssues) { + var params = new HashMap>(); + for (RawParam rawParam : rawParams) { + validateParam(rawParam, recordedIssues); + if (params.containsKey(rawParam.name())) { + recordedIssues.add(Issue.error( + "Duplicate parameter names are not allowed. '%s' is already defined as parameter".formatted( + rawParam.name()))); + } else { + params.put(rawParam.name(), Optional.ofNullable(rawParam.value())); + } + } + return params; + } + + private void validateParam(RawParam rawParam, List recordedIssues) { + if (tokenContainsInvalidChars(rawParam.name())) { + recordedIssues.add( + Issue.error("Invalid parameter name '%s': Only the characters '%s' are allowed".formatted( + rawParam.name(), ALLOWED_TOKEN_CHARS.pattern()))); + } + } + + private static boolean tokenContainsInvalidChars(String token) { + return !ALLOWED_TOKEN_CHARS.matcher(token).matches(); + } + +} diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/parsing/WebLinkParserSpec.groovy similarity index 99% rename from fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy rename to fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/parsing/WebLinkParserSpec.groovy index e226cc912..b8aeebf18 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkParserSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/parsing/WebLinkParserSpec.groovy @@ -1,7 +1,7 @@ -package life.qbic.datamanager.signposting.http +package life.qbic.datamanager.signposting.http.parsing +import life.qbic.datamanager.signposting.http.WebLinkParser import life.qbic.datamanager.signposting.http.lexing.SimpleWebLinkLexer -import life.qbic.datamanager.signposting.http.parsing.SimpleWebLinkParser import spock.lang.Specification class WebLinkParserSpec extends Specification { diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy new file mode 100644 index 000000000..8d763f34a --- /dev/null +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy @@ -0,0 +1,168 @@ +package life.qbic.datamanager.signposting.http.validation + +import life.qbic.datamanager.signposting.http.Validator +import life.qbic.datamanager.signposting.http.WebLink +import life.qbic.datamanager.signposting.http.parsing.RawLink +import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader +import life.qbic.datamanager.signposting.http.parsing.RawParam +import spock.lang.Specification + +/** + * Specification for {@link Rfc8288Validator}. + * + * Covers basic RFC 8288 semantics: + *

    + *
  • Valid URIs create {@link WebLink} instances without issues.
  • + *
  • Invalid URIs create error {@link Validator.Issue}s and no WebLink for that entry.
  • + *
  • Multiple links are all validated; one invalid URI does not stop validation.
  • + *
  • Unknown / extension parameters are preserved and do not cause issues.
  • + *
+ * + * @since + */ +class Rfc8288ValidatorSpec extends Specification { + + /** + * Valid single link with a syntactically correct absolute URI + * should yield one WebLink and no issues. + */ + def "single valid link produces one WebLink and no issues"() { + given: + def rawHeader = new RawLinkHeader([ + new RawLink("https://example.org/resource", []) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "no issues are reported" + !result.containsIssues() + !result.report().hasErrors() + !result.report().hasWarnings() + + and: "exactly one WebLink is produced with the expected URI and empty params" + result.weblinks().size() == 1 + WebLink link = result.weblinks().first() + link.reference().toString() == "https://example.org/resource" + link.params().isEmpty() + } + + /** + * A link with an invalid URI string should not yield a WebLink instance, + * but should record at least one error Issue. + */ + def "single invalid URI produces error issue and no WebLinks"() { + given: + // 'not a uri' will fail URI.create(...) + def rawHeader = new RawLinkHeader([ + new RawLink("not a uri", []) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "an error is reported" + result.containsIssues() + result.report().hasErrors() + + and: "no WebLinks are produced for invalid URIs" + result.weblinks().isEmpty() + } + + /** + * When there are multiple links and one has an invalid URI, + * the validator should still validate all links and produce + * WebLinks for the valid ones. + */ + def "multiple links - one invalid URI does not prevent valid WebLinks"() { + given: + def rawHeader = new RawLinkHeader([ + new RawLink("not a uri", []), + new RawLink("https://example.org/valid", []) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "at least one error is reported for the invalid entry" + result.containsIssues() + result.report().hasErrors() + + and: "the valid URI still yields a WebLink" + result.weblinks().size() == 1 + result.weblinks().first().reference().toString() == "https://example.org/valid" + } + + /** + * Unknown / extension parameters should be preserved on the WebLink + * and must not trigger errors at RFC 8288 level. + * + * Example: Link: ; foo="bar" + */ + def "unknown extension parameters are preserved and do not cause issues"() { + given: + def params = [new RawParam("x-custom", "value")] // arbitrary extension parameter + def rawHeader = new RawLinkHeader([ + new RawLink("https://example.org/with-param", params) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "no errors are reported for unknown parameters" + !result.report().hasErrors() + + and: "at RFC level, we do not warn about extension parameters either (optional; adjust if you decide to warn)" + !result.report().hasWarnings() + + and: "the parameter is preserved on the resulting WebLink" + result.weblinks().size() == 1 + def link = result.weblinks().first() + link.params().get("x-custom").get() == "value" + } + + /** + * A parameter without a value (e.g. 'rel' without '=...') is structurally + * allowed in RFC 8288. At the RFC semantic level we accept it and leave any + * deeper interpretation to profile-specific validators (e.g. Signposting). + * + * How you map "no value" into your RawLink/WebLink model is up to your + * implementation; here we assume null or empty string is used to represent it. + */ + def "parameter without value is accepted at RFC level"() { + given: + // Example representation: parameter present with null value. + // Adapt this to your actual RawLink model. + def params = [new RawParam("rel", null)] + def rawHeader = new RawLinkHeader([ + new RawLink("https://example.org/no-value-param", params) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "URI is valid, so we get a WebLink back" + result.weblinks().size() == 1 + + and: "parameter without value does not cause an error at RFC-level" + !result.report().hasErrors() + + // You may or may not decide to warn here; if you later choose to warn, adjust this: + // !result.report().hasWarnings() + } +} From 3083c6d69aaf464053ec2f0d98c8c0c7882503cd Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Sun, 23 Nov 2025 21:13:37 +0100 Subject: [PATCH 13/20] Refine validation --- .../datamanager/signposting/http/LinkParameter.java | 10 ++++++++++ .../signposting/http/validation/RfcLinkParameter.java | 9 +++++++++ 2 files changed, 19 insertions(+) create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java new file mode 100644 index 000000000..da438e854 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java @@ -0,0 +1,10 @@ +package life.qbic.datamanager.signposting.http; + +/** + * + * + * @since + */ +public record WebLinkParameter(String name, String value) { + +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java new file mode 100644 index 000000000..e31d5a58b --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java @@ -0,0 +1,9 @@ +package life.qbic.datamanager.signposting.http.validation; + +/** + * + * + * @since + */ +public enum LinkParameters { +} From 52bbdd22ef9a0d79da4bc4ebd6d1767ff30913da Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Sun, 23 Nov 2025 21:14:09 +0100 Subject: [PATCH 14/20] Refine validation --- .../signposting/http/LinkParameter.java | 14 ++++- .../datamanager/signposting/http/WebLink.java | 39 ++++++------ .../http/validation/Rfc8288Validator.java | 44 +++++++++---- .../http/validation/RfcLinkParameter.java | 63 ++++++++++++++++++- .../validation/Rfc8288ValidatorSpec.groovy | 52 +++++++++++++++ 5 files changed, 175 insertions(+), 37 deletions(-) diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java index da438e854..97e2a03da 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java @@ -1,10 +1,22 @@ package life.qbic.datamanager.signposting.http; +import java.util.Optional; + /** * * * @since */ -public record WebLinkParameter(String name, String value) { +public record LinkParameter(String name, String value) { + public static LinkParameter create(String name, String value) { + return new LinkParameter(name, value); + } + + public static LinkParameter createWithoutValue(String name) { + return new LinkParameter(name, null); + } + public Optional optionalValue() { + return Optional.ofNullable(value); + } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java index d55ccb933..1a48c6b28 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java @@ -1,16 +1,15 @@ package life.qbic.datamanager.signposting.http; import java.net.URI; -import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; /** * A Java record representing a web link object following the * RFC 8288 model specification. */ -public record WebLink(URI reference, Map> params) { +public record WebLink(URI reference, List params) { /** * Creates an RFC 8288 compliant web @@ -26,49 +25,47 @@ public record WebLink(URI reference, Map> params) { * @param reference a {@link URI} pointing to the actual resource * @param params a {@link Map} of parameters as keys and a list of their values * @return the new Weblink - * @throws FormatException if the parameters violate any known specification described in the RFC + * @throws FormatException if the parameters violate any known specification described in the + * RFC * @throws NullPointerException if any method argument is {@code null} */ - public static WebLink create(URI reference, Map> params) + public static WebLink create(URI reference, List params) throws FormatException, NullPointerException { Objects.requireNonNull(reference); Objects.requireNonNull(params); - if (hasEmptyParameterKey(params)) { - throw new FormatException("A parameter key must not be empty"); - } return new WebLink(reference, params); } /** * Web link constructor that can be used if a web link has no parameters. *

- * See {@link WebLink#create(URI, Map)} for the full description. * * @param reference a {@link URI} pointing to the actual resource * @return the new Weblink - * @throws FormatException if the parameters violate any known specification described in the RFC + * @throws FormatException if the parameters violate any known specification described in the + * RFC * @throws NullPointerException if any method argument is {@code null} */ public static WebLink create(URI reference) throws FormatException, NullPointerException { - return create(reference, new HashMap<>()); + return create(reference, List.of()); } /** - * Verifies the {@code token} has at least one character or more. + * Returns all "rel" parameter values of the link. *

- * See RFC 8288 and RFC 7230 section 3.2.6: + * RFC 8288 section 3.3 states, that the relation parameter MUST NOT appear more than once in a + * given link-value, but one "rel" parameter value can contain multiple relation-types when + * separated by one or more space characters (SP = ASCII 0x20): *

- * {@code link-param = token BWS [ "=" BWS ( token / quoted-string ) ]} + * {@code relation-type *( 1*SP relation-type ) }. *

- * The parameter key must not be empty, so during construction the {@code params} keys are checked - * for an empty key. The values can be empty though. + * The method returns space-separated values as individual values of the "rel" parameter. * - * @param params the parameter map to check for an empty parameter key - * @return {@code true}, if an empty parameter key exists, else {@code false} + * @return a list of relation parameter values */ - private static boolean hasEmptyParameterKey(Map> params) { - return params.containsKey(""); + public List relations() { + return List.of(); } + } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java index 457d52edc..c1596a60a 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java @@ -2,11 +2,11 @@ import java.net.URI; import java.util.ArrayList; -import java.util.HashMap; +import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.Set; import java.util.regex.Pattern; +import life.qbic.datamanager.signposting.http.LinkParameter; import life.qbic.datamanager.signposting.http.Validator; import life.qbic.datamanager.signposting.http.WebLink; import life.qbic.datamanager.signposting.http.parsing.RawLink; @@ -54,18 +54,13 @@ private WebLink validate(RawLink rawLink, List recordedIssues) { return new WebLink(uri, parameters); } - private Map> validateParams( + private List validateParams( List rawParams, List recordedIssues) { - var params = new HashMap>(); + var params = new ArrayList(); + var seenParams = new HashSet(); for (RawParam rawParam : rawParams) { validateParam(rawParam, recordedIssues); - if (params.containsKey(rawParam.name())) { - recordedIssues.add(Issue.error( - "Duplicate parameter names are not allowed. '%s' is already defined as parameter".formatted( - rawParam.name()))); - } else { - params.put(rawParam.name(), Optional.ofNullable(rawParam.value())); - } + validateParamOccurrence(rawParam, seenParams, params, recordedIssues); } return params; } @@ -82,4 +77,29 @@ private static boolean tokenContainsInvalidChars(String token) { return !ALLOWED_TOKEN_CHARS.matcher(token).matches(); } + private void validateParamOccurrence( + RawParam rawParam, + Set seenParams, + List parameters, + List recordedIssues) { + var rfcParamOptional = RfcLinkParameter.from(rawParam.name()); + + if (rfcParamOptional.isPresent()) { + var rfcParam = rfcParamOptional.get(); + if (seenParams.contains(rawParam.name()) && !rfcParam.equals(RfcLinkParameter.HREFLANG)) { + recordedIssues.add(Issue.warning( + "Parameter '%s' is not allowed multiple times. Skipped parameter.".formatted(rfcParam.rfcValue()))); + return; + } + } + seenParams.add(rawParam.name()); + + LinkParameter linkParameter; + if (rawParam.value() == null || rawParam.value().isEmpty()) { + linkParameter = LinkParameter.createWithoutValue(rawParam.name()); + } else { + linkParameter = LinkParameter.create(rawParam.name(), rawParam.value()); + } + parameters.add(linkParameter); + } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java index e31d5a58b..27804f5d9 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java @@ -1,9 +1,66 @@ package life.qbic.datamanager.signposting.http.validation; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + /** - * + * Standard parameters for the {@code Link} HTTP header. * - * @since + *

    + *
  • "anchor" - see RFC 8288 section 3.2 (“Link Context”)
  • + *
  • "hreflang" - see RFC 8288 section 3.4.1 (“The hreflang Target Attribute”)
  • + *
  • "media" - see RFC 8288 section 3.4.2 (“The media Target Attribute”)
  • + *
  • "rel" - see RFC 8288 section 3.3 (“Relation Types”)
  • + *
  • "rev" - see RFC 8288 section 3.3 (historical note)
  • + *
  • "title" - see RFC 8288 section 3.4.4 (“The title Target Attribute”)
  • + *
  • "title*" - see RFC 8288 section 3.4.4 references RFC 5987 (“Character Set and Language Encoding for HTTP Header Field Parameters”)
  • + *
  • "type" - see RFC 8288 section 3.4.3 (“The type Target Attribute”)
  • + *
*/ -public enum LinkParameters { +public enum RfcLinkParameter { + + ANCHOR("anchor"), + HREFLANG("hreflang"), + MEDIA("media"), + REL("rel"), + REV("rev"), + TITLE("title"), + TITLE_MULT("title*"), + TYPE("type"); + + private final String value; + + private static final Map LOOKUP = new HashMap<>(); + + static { + for (RfcLinkParameter p : RfcLinkParameter.values()) { + LOOKUP.put(p.value, p); + } + } + + RfcLinkParameter(String value) { + this.value = value; + } + + /** + * Returns the RFC compliant value of the parameter name. + * + * @return the alpha-value of the link parameter + */ + public String rfcValue() { + return value; + } + + /** + * Creates an RfcLinkParameter from a given value, if the value belongs to any existing enum of + * this type. + * + * @param value the value to match the corresponding enum value + * @return the corresponding enum in an Optional, of returns Optional.empty() + */ + public static Optional from(String value) { + return Optional.ofNullable(LOOKUP.getOrDefault(value, null)); + } + } diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy index 8d763f34a..f466a5ad8 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy @@ -165,4 +165,56 @@ class Rfc8288ValidatorSpec extends Specification { // You may or may not decide to warn here; if you later choose to warn, adjust this: // !result.report().hasWarnings() } + + def "parameter anchor with one occurrence is allowed"() { + given: + // Example representation: parameter present with null value. + // Adapt this to your actual RawLink model. + def params = [new RawParam("anchor", "https://example.org/one-anchor-only")] + def rawHeader = new RawLinkHeader([ + new RawLink("https://example.org/one-anchor-only", params) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "URI is valid, so we get a WebLink back" + result.weblinks().size() == 1 + + and: "parameter anchor with only one occurrence does not cause an error at RFC-level" + !result.report().hasErrors() + + // You may or may not decide to warn here; if you later choose to warn, adjust this: + // !result.report().hasWarnings() + } + + def "parameter anchor must not have multiple occurrences"() { + given: + // Example representation: parameter present with null value. + // Adapt this to your actual RawLink model. + def params = [new RawParam("anchor", "https://example.org/one-anchor-only"), + new RawParam("anchor", "https://example.org/another-anchor")] + def rawHeader = new RawLinkHeader([ + new RawLink("https://example.org/one-anchor-only", params) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "URI is valid, so we get a WebLink back" + result.weblinks().size() == 1 + + and: "parameter anchor with only one occurrence does not cause an error at RFC-level" + result.report().hasWarnings() + result.report().issues().size() == 1 + + // You may or may not decide to warn here; if you later choose to warn, adjust this: + // !result.report().hasWarnings() + } } From 67b7f3436337641d652248906800a3fdb5903338 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Mon, 24 Nov 2025 17:17:37 +0100 Subject: [PATCH 15/20] Support link attribute extensions --- .../signposting/http/LinkParameter.java | 22 ---- .../datamanager/signposting/http/WebLink.java | 80 ++++++++++++- .../signposting/http/WebLinkParameter.java | 22 ++++ .../signposting/http/parsing/RawParam.java | 27 ++++- .../http/validation/Rfc8288Validator.java | 105 +++++++++++++++--- .../validation/Rfc8288ValidatorSpec.groovy | 72 ++++++++++-- 6 files changed, 274 insertions(+), 54 deletions(-) delete mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParameter.java diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java deleted file mode 100644 index 97e2a03da..000000000 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/LinkParameter.java +++ /dev/null @@ -1,22 +0,0 @@ -package life.qbic.datamanager.signposting.http; - -import java.util.Optional; - -/** - * - * - * @since - */ -public record LinkParameter(String name, String value) { - public static LinkParameter create(String name, String value) { - return new LinkParameter(name, value); - } - - public static LinkParameter createWithoutValue(String name) { - return new LinkParameter(name, null); - } - - public Optional optionalValue() { - return Optional.ofNullable(value); - } -} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java index 1a48c6b28..4033995d5 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java @@ -1,15 +1,20 @@ package life.qbic.datamanager.signposting.http; import java.net.URI; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import life.qbic.datamanager.signposting.http.validation.RfcLinkParameter; /** * A Java record representing a web link object following the * RFC 8288 model specification. */ -public record WebLink(URI reference, List params) { +public record WebLink(URI reference, List params) { /** * Creates an RFC 8288 compliant web @@ -29,7 +34,7 @@ public record WebLink(URI reference, List params) { * RFC * @throws NullPointerException if any method argument is {@code null} */ - public static WebLink create(URI reference, List params) + public static WebLink create(URI reference, List params) throws FormatException, NullPointerException { Objects.requireNonNull(reference); Objects.requireNonNull(params); @@ -50,6 +55,19 @@ public static WebLink create(URI reference) throws FormatException, NullPointerE return create(reference, List.of()); } + + public Optional anchor() { + return Optional.empty(); + } + + public List hreflang() { + return List.of(); + } + + public Optional media() { + return Optional.empty(); + } + /** * Returns all "rel" parameter values of the link. *

@@ -63,9 +81,63 @@ public static WebLink create(URI reference) throws FormatException, NullPointerE * * @return a list of relation parameter values */ - public List relations() { - return List.of(); + public List rel() { + return this.params.stream() + .filter(param -> param.name().equals("rel")) + .map(WebLinkParameter::value) + .map(value -> value.split("\\s+")) + .flatMap(Arrays::stream) + .toList(); + } + + /** + * Returns all "rev" parameter values of the link. + *

+ * RFC 8288 section 3.3 does not specify the multiplicity of occurrence. But given the close + * relation to the "rel" parameter and its definition in the same section, web link will treat the + * "rev" parameter equally. + *

+ * As with the "rel" parameter, multiple regular relation types are allowed when they are + * separated by one or more space characters (SP = ASCII 0x20): + *

+ * {@code relation-type *( 1*SP relation-type ) }. + *

+ * The method returns space-separated values as individual values of the "rel" parameter. + * + * @return a list of relation parameter values + */ + public List rev() { + return this.params.stream() + .filter(param -> param.name().equals("rev")) + .map(WebLinkParameter::value) + .map(value -> value.split("\\s+")) + .flatMap(Arrays::stream) + .toList(); } + public Optional title() { + return Optional.empty(); + } + public Optional titleMultiple() { + return Optional.empty(); + } + + public Optional type() { + return Optional.empty(); + } + + public Map> extensionAttributes() { + Set rfcParameterNames = Arrays.stream(RfcLinkParameter.values()) + .map(RfcLinkParameter::rfcValue) + .collect(Collectors.toSet()); + return this.params.stream() + .filter(param -> !rfcParameterNames.contains(param.name())) + .collect(Collectors.groupingBy(WebLinkParameter::name, + Collectors.mapping(WebLinkParameter::value, Collectors.toList()))); + } + + public List extensionAttribute(String name) { + return extensionAttributes().getOrDefault(name, List.of()); + } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParameter.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParameter.java new file mode 100644 index 000000000..4b6e2eb3a --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParameter.java @@ -0,0 +1,22 @@ +package life.qbic.datamanager.signposting.http; + +import java.util.Optional; + +/** + * + * + * @since + */ +public record WebLinkParameter(String name, String value) { + public static WebLinkParameter create(String name, String value) { + return new WebLinkParameter(name, value); + } + + public static WebLinkParameter createWithoutValue(String name) { + return new WebLinkParameter(name, null); + } + + public Optional optionalValue() { + return Optional.ofNullable(value); + } +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java index 7503ed9d7..a24162c74 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java @@ -9,11 +9,34 @@ */ public record RawParam(String name, String value) { + /** + * Creates an empty raw parameter, that only has a name. + *

+ * A call to {@link #value()} will return {@code null} for empty parameters. + * + * @param name the name of the parameter + * @return an empty raw parameter with a name only + */ public static RawParam emptyParameter(String name) { - return new RawParam(name, ""); + return new RawParam(name, null); } - public static RawParam withValue(String name, String value) { + /** + * Creates a raw parameter with name and value. + *

+ * The client must not pass empty or blank values as parameter value, but shall call + * {@link #emptyParameter(String)} explicitly. Alternatively, the client can also pass + * {@code null} for value, to indicate an empty parameter. + * + * @param name the name of the parameter + * @param value the value of the parameter + * @return a raw parameter + * @throws IllegalArgumentException in case the value is empty or blank + */ + public static RawParam withValue(String name, String value) throws IllegalArgumentException { + if (value != null && value.isBlank()) { + throw new IllegalArgumentException("Value cannot be blank"); + } return new RawParam(name, value); } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java index c1596a60a..02b14cdba 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.Set; import java.util.regex.Pattern; -import life.qbic.datamanager.signposting.http.LinkParameter; +import life.qbic.datamanager.signposting.http.WebLinkParameter; import life.qbic.datamanager.signposting.http.Validator; import life.qbic.datamanager.signposting.http.WebLink; import life.qbic.datamanager.signposting.http.parsing.RawLink; @@ -14,9 +14,19 @@ import life.qbic.datamanager.signposting.http.parsing.RawParam; /** - * + * Validation against RFC 8288 Web Linking. + *

+ * Violations against the specification will be recorded as + * {@link life.qbic.datamanager.signposting.http.Validator.IssueType#ERROR}. In the presence of at + * least one error, the web link MUST be regarded invalid and clients shall not continue to work + * with the link, but treat it as exception. + *

+ * The implementation also records issues as + * {@link life.qbic.datamanager.signposting.http.Validator.IssueType#WARNING}, in case the finding + * is not strictly against the RFC 8288, but e.g. a type usage is deprecated or when parameters have + * been skipped when the specification demands for it. A warning results in a still usable web link, + * but it is advised to investigate any findings. * - * @since */ public class Rfc8288Validator implements Validator { @@ -38,6 +48,16 @@ public ValidationResult validate(RawLinkHeader rawLinkHeader) { return new ValidationResult(webLinks, new IssueReport(List.copyOf(recordedIssues))); } + /** + * Validation entry point for a single raw link. Any findings must be recorded in the provided + * issue list. Only issue additions are allowed. + *

+ * In case the target is not a valid URI, the returned web link is {@code null}. + * + * @param rawLink the raw link information from parsing + * @param recordedIssues a list to record negative findings as warnings and errors + * @return a web link object, or {@code null}, in case the target is not a valid URI + */ private WebLink validate(RawLink rawLink, List recordedIssues) { URI uri = null; try { @@ -46,7 +66,7 @@ private WebLink validate(RawLink rawLink, List recordedIssues) { recordedIssues.add( Issue.error("Invalid URI '%s': %s".formatted(rawLink.rawURI(), e.getMessage()))); } - var parameters = validateParams(rawLink.rawParameters(), recordedIssues); + var parameters = validateAndConvertParams(rawLink.rawParameters(), recordedIssues); if (uri == null) { return null; @@ -54,17 +74,40 @@ private WebLink validate(RawLink rawLink, List recordedIssues) { return new WebLink(uri, parameters); } - private List validateParams( + /** + * Validates a list of raw parameters and creates a list of link parameters that can be used to + * build the final web link object. + *

+ * Any error or warning will be recorded in the provided recorded issue list. + * + * @param rawParams a list of raw parameter values + * @param recordedIssues a list of recorded issues to add more findings during validation + * @return a list of converted link parameters + */ + private List validateAndConvertParams( List rawParams, List recordedIssues) { - var params = new ArrayList(); + var params = new ArrayList(); var seenParams = new HashSet(); for (RawParam rawParam : rawParams) { validateParam(rawParam, recordedIssues); - validateParamOccurrence(rawParam, seenParams, params, recordedIssues); + validateParamOccurrenceAndAddLink(rawParam, seenParams, params, recordedIssues); } return params; } + /** + * Validates a given raw parameter against known constraints and assumptions in the RFC 8288 + * specification. + *

+ * Currently, checks: + * + *

    + *
  • the parameter name MUST contain allowed characters only (see token definition)
  • + *
+ * + * @param rawParam the raw parameter to be validated + * @param recordedIssues a list of issues to record more findings + */ private void validateParam(RawParam rawParam, List recordedIssues) { if (tokenContainsInvalidChars(rawParam.name())) { recordedIssues.add( @@ -73,33 +116,61 @@ private void validateParam(RawParam rawParam, List recordedIssues) { } } + /** + * Looks for the presence of invalid chars. + *

+ * Allowed token chars are defined by RFC + * 7230, section 3.2.6. + * + * @param token the token to be checked for invalid characters + * @return true, if the token violates the token character specification, else false + */ private static boolean tokenContainsInvalidChars(String token) { return !ALLOWED_TOKEN_CHARS.matcher(token).matches(); } - private void validateParamOccurrence( + /** + * Validates parameter occurrence rules and honors the RFC 8288 specification for skipping + * parameter entries. + *

+ * Sofar multiple definitions are only allowed for the "hreflang" parameter. + *

+ * Note: occurrences after the first are ignored and issue a warning. This is a strict requirement + * from the RFC 8288 and must be honored. + * + * @param rawParam the raw parameter value + * @param recordedParameterNames a set to check, if a parameter has been already seen in the link + * @param parameters a list of converted link parameters for the final web link + * object + * @param recordedIssues a list of issue records to add new findings + */ + private void validateParamOccurrenceAndAddLink( RawParam rawParam, - Set seenParams, - List parameters, + Set recordedParameterNames, + List parameters, List recordedIssues) { var rfcParamOptional = RfcLinkParameter.from(rawParam.name()); if (rfcParamOptional.isPresent()) { var rfcParam = rfcParamOptional.get(); - if (seenParams.contains(rawParam.name()) && !rfcParam.equals(RfcLinkParameter.HREFLANG)) { + // the "hreflang" parameter is the only parameter that is allowed to occur more than once + // see RFC 8288 for the parameter multiplicity definition + if (recordedParameterNames.contains(rawParam.name()) && !rfcParam.equals( + RfcLinkParameter.HREFLANG)) { recordedIssues.add(Issue.warning( - "Parameter '%s' is not allowed multiple times. Skipped parameter.".formatted(rfcParam.rfcValue()))); + "Parameter '%s' is not allowed multiple times. Skipped parameter.".formatted( + rfcParam.rfcValue()))); return; } } - seenParams.add(rawParam.name()); + recordedParameterNames.add(rawParam.name()); - LinkParameter linkParameter; + WebLinkParameter webLinkParameter; if (rawParam.value() == null || rawParam.value().isEmpty()) { - linkParameter = LinkParameter.createWithoutValue(rawParam.name()); + webLinkParameter = WebLinkParameter.createWithoutValue(rawParam.name()); } else { - linkParameter = LinkParameter.create(rawParam.name(), rawParam.value()); + webLinkParameter = WebLinkParameter.create(rawParam.name(), rawParam.value()); } - parameters.add(linkParameter); + parameters.add(webLinkParameter); } } diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy index f466a5ad8..6309425c8 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy @@ -130,7 +130,7 @@ class Rfc8288ValidatorSpec extends Specification { and: "the parameter is preserved on the resulting WebLink" result.weblinks().size() == 1 def link = result.weblinks().first() - link.params().get("x-custom").get() == "value" + link.extensionAttribute("x-custom")[0] == "value" } /** @@ -161,9 +161,6 @@ class Rfc8288ValidatorSpec extends Specification { and: "parameter without value does not cause an error at RFC-level" !result.report().hasErrors() - - // You may or may not decide to warn here; if you later choose to warn, adjust this: - // !result.report().hasWarnings() } def "parameter anchor with one occurrence is allowed"() { @@ -186,11 +183,71 @@ class Rfc8288ValidatorSpec extends Specification { and: "parameter anchor with only one occurrence does not cause an error at RFC-level" !result.report().hasErrors() + } + + def "a parameter with allowed multiplicity of 1 must be only processed on the first occurrence"() { + given: + // Example representation: parameter present with null value. + // Adapt this to your actual RawLink model. + def firstParam = new RawParam("rel", "https://example.org/first-occurrence") + def secondParam = new RawParam("rel", "https://example.org/next-occurrence") + def params = [firstParam, secondParam] + def rawHeader = new RawLinkHeader([ + new RawLink("https://example.org/one-anchor-only", params) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "URI is valid, so we get a WebLink back" + result.weblinks().size() == 1 + + and: "parameter rel with only one occurrence does not cause an error at RFC-level" + !result.report().hasErrors() + + and: "but results in a warning, since the second occurrence is skipped" + result.report().hasWarnings() + + and: "uses only the value of the first occurrence" + var relations = result.weblinks().get(0).rel() + relations.size() == 1 + relations.get(0).equals(firstParam.value()) + } + + def "the rel parameter can contain multiple relations as whitespace-separated list"() { + given: + // Example representation: parameter present with null value. + // Adapt this to your actual RawLink model. + def firstParam = new RawParam("rel", "self describedby another") + def params = [firstParam] + def rawHeader = new RawLinkHeader([ + new RawLink("https://example.org/one-anchor-only", params) + ]) + + and: + def validator = new Rfc8288Validator() + + when: + Validator.ValidationResult result = validator.validate(rawHeader) + + then: "URI is valid, so we get a WebLink back" + result.weblinks().size() == 1 + + and: "parameter rel with only one occurrence does not cause an error at RFC-level" + !result.report().hasErrors() + + and: "results in no warnings" + !result.report().hasWarnings() - // You may or may not decide to warn here; if you later choose to warn, adjust this: - // !result.report().hasWarnings() + and: "splits the relations into three values" + var relations = result.weblinks().get(0).rel() + relations.size() == 3 } + def "parameter anchor must not have multiple occurrences"() { given: // Example representation: parameter present with null value. @@ -213,8 +270,5 @@ class Rfc8288ValidatorSpec extends Specification { and: "parameter anchor with only one occurrence does not cause an error at RFC-level" result.report().hasWarnings() result.report().issues().size() == 1 - - // You may or may not decide to warn here; if you later choose to warn, adjust this: - // !result.report().hasWarnings() } } From a49783df6ab30975eb14718bc255aa85817079f1 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Tue, 25 Nov 2025 11:00:10 +0100 Subject: [PATCH 16/20] Provide more documentation --- .../datamanager/signposting/http/WebLink.java | 4 +- .../signposting/http/WebLinkParameter.java | 63 ++++++++++++++++--- .../signposting/http/WebLinkParser.java | 28 +++++++-- .../signposting/http/parsing/RawParam.java | 12 ++-- .../http/parsing/SimpleWebLinkParser.java | 6 +- .../http/validation/Rfc8288Validator.java | 2 +- .../http/validation/RfcLinkParameter.java | 2 +- .../http/parsing/WebLinkParserSpec.groovy | 14 ++--- .../validation/Rfc8288ValidatorSpec.groovy | 4 +- 9 files changed, 103 insertions(+), 32 deletions(-) diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java index 4033995d5..96c35bc4e 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java @@ -24,8 +24,8 @@ public record WebLink(URI reference, List params) { *

* {@code link-param = token BWS [ "=" BWS ( token / quoted-string ) ]} *

- * The parameter key must not be empty, so during construction the {@code params} keys are checked - * for an empty key. The values can be empty though. + * The parameter key must not be withoutValue, so during construction the {@code params} keys are checked + * for an withoutValue key. The values can be withoutValue though. * * @param reference a {@link URI} pointing to the actual resource * @param params a {@link Map} of parameters as keys and a list of their values diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParameter.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParameter.java index 4b6e2eb3a..0d3c6fec9 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParameter.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParameter.java @@ -1,22 +1,71 @@ package life.qbic.datamanager.signposting.http; -import java.util.Optional; - /** - * + * A parameter for the HTTP Link header attribute. + *

+ * Based on RFC 8288, a parameter with only a name is valid. + *

+ *

+ * {@code
+ * // ABNF notation for web links
+ * Link = #link-value
+ * link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param )
+ * link-param = token BWS [ "=" BWS ( token / quoted-string ) ]
+ *
+ * // valid parameter examples
+ * "Link: ; rel; param1;"
+ * "Link: ; rel="self"; param1="";"
+ * }
+ * 
+ *

+ * It is important that different parameter serialisation cases are handled correctly. + *

+ * The following example shows three distinct cases that must be preserved during de-serialisation: + * + *

+ * {@code
+ * x=""  // empty double-quoted string
+ * x="y" // double-quoted with content
+ * x=y   // token value
+ * x     // parameter name only
+ * }
+ * 
+ *

+ * These are all valid parameter serialisations. + * * - * @since */ public record WebLinkParameter(String name, String value) { + + /** + * Creates a new web link parameter with the provided name and value. + * + * @param name the name of the web link parameter + * @param value the value of the web link parameter + */ public static WebLinkParameter create(String name, String value) { return new WebLinkParameter(name, value); } - public static WebLinkParameter createWithoutValue(String name) { + /** + * Creates a new web link parameter without a value. + * + * @param name the name of the parameter + */ + public static WebLinkParameter withoutValue(String name) { return new WebLinkParameter(name, null); } - public Optional optionalValue() { - return Optional.ofNullable(value); + /** + * Checks if the web link parameter has a value. + *

+ * The method will return {@code true} only when a value (including an empty one) has been + * provided. + * + * @return {@code true}, if the parameter has a value (including an empty one). Returns + * {@code false}, if no value has been provided + */ + public boolean hasValue() { + return value != null; } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java index f462d8364..659b918fe 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java @@ -5,11 +5,31 @@ import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader; /** - * + * A parser that checks structural integrity of an HTTP Link header entry in compliance with RFC 8288. + *

+ * A web link parser is able to process tokens from web link lexing and convert the tokens to raw + * link headers after structural validation, which can be seen as an AST (abstract syntax tree). + *

+ * Note: Implementations must not perform semantic validation, this is concern of + * {@link Validator} implementations. + *

+ * In case of structural violations, implementations of the {@link WebLinkParser} interface must + * throw a {@link StructureException}. + *

+ * RFC 8288 section 3 describes the serialization of the Link HTTP header attribute: * - *

- * - * @since + *
+ *   {@code
+ *   Link       = #link-value
+ *   link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param )
+ *   link-param = token BWS [ "=" BWS ( token / quoted-string ) ]
+ *   }
+ * 
+ *

+ * The {@link WebLinkParser} interface can process {@link WebLinkToken}, which are the output of + * lexing raw character values into known token values. See {@link WebLinkLexer} for details to + * lexers. */ public interface WebLinkParser { diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java index a24162c74..3abdfac93 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/RawParam.java @@ -10,12 +10,12 @@ public record RawParam(String name, String value) { /** - * Creates an empty raw parameter, that only has a name. + * Creates an withoutValue raw parameter, that only has a name. *

- * A call to {@link #value()} will return {@code null} for empty parameters. + * A call to {@link #value()} will return {@code null} for withoutValue parameters. * * @param name the name of the parameter - * @return an empty raw parameter with a name only + * @return an withoutValue raw parameter with a name only */ public static RawParam emptyParameter(String name) { return new RawParam(name, null); @@ -24,14 +24,14 @@ public static RawParam emptyParameter(String name) { /** * Creates a raw parameter with name and value. *

- * The client must not pass empty or blank values as parameter value, but shall call + * The client must not pass withoutValue or blank values as parameter value, but shall call * {@link #emptyParameter(String)} explicitly. Alternatively, the client can also pass - * {@code null} for value, to indicate an empty parameter. + * {@code null} for value, to indicate an withoutValue parameter. * * @param name the name of the parameter * @param value the value of the parameter * @return a raw parameter - * @throws IllegalArgumentException in case the value is empty or blank + * @throws IllegalArgumentException in case the value is withoutValue or blank */ public static RawParam withValue(String name, String value) throws IllegalArgumentException { if (value != null && value.isBlank()) { diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java index 5a3126145..0594fcf24 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java @@ -15,6 +15,8 @@ *

* The implementation is based on the Link Serialisation in HTTP Headers, section 3 of the * RFC 8288. + *

+ * Note: the implementation of this class is NOT thread-safe. * *

* @@ -71,7 +73,7 @@ public RawLinkHeader parse(List tokens) if (tokens.isEmpty()) { throw new StructureException( - "A link header entry must have at least one web link. Tokens were empty."); + "A link header entry must have at least one web link. Tokens were withoutValue."); } // Always reset the internal state on every parse() call @@ -190,7 +192,7 @@ private RawParam parseParameter() throws StructureException { next(); - // Checks for empty parameter + // Checks for withoutValue parameter if (currentIsEof() || current().type() == WebLinkTokenType.COMMA || current().type() == WebLinkTokenType.SEMICOLON diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java index 02b14cdba..e0ced64a8 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288Validator.java @@ -167,7 +167,7 @@ private void validateParamOccurrenceAndAddLink( WebLinkParameter webLinkParameter; if (rawParam.value() == null || rawParam.value().isEmpty()) { - webLinkParameter = WebLinkParameter.createWithoutValue(rawParam.name()); + webLinkParameter = WebLinkParameter.withoutValue(rawParam.name()); } else { webLinkParameter = WebLinkParameter.create(rawParam.name(), rawParam.value()); } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java index 27804f5d9..d56c6a2a2 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/RfcLinkParameter.java @@ -57,7 +57,7 @@ public String rfcValue() { * this type. * * @param value the value to match the corresponding enum value - * @return the corresponding enum in an Optional, of returns Optional.empty() + * @return the corresponding enum in an Optional, of returns Optional.withoutValue() */ public static Optional from(String value) { return Optional.ofNullable(LOOKUP.getOrDefault(value, null)); diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/parsing/WebLinkParserSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/parsing/WebLinkParserSpec.groovy index b8aeebf18..513462aef 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/parsing/WebLinkParserSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/parsing/WebLinkParserSpec.groovy @@ -355,7 +355,7 @@ class WebLinkParserSpec extends Specification { } /** - * Why invalid: A trailing comma indicates an empty link value, which is invalid. + * Why invalid: A trailing comma indicates an withoutValue link value, which is invalid. * Spec: RFC 8288 Section 3, link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param )” */ def "No trailing comma allowed for multiple link values"() { @@ -438,7 +438,7 @@ class WebLinkParserSpec extends Specification { } /** - * Why invalid: link-param must start with token; an empty name before equal sign violates token = 1*tchar. + * Why invalid: link-param must start with token; an withoutValue name before equal sign violates token = 1*tchar. * Spec: RFC 8288 section 3, link-param = token ...; RFC 7230 section 3.2.6 (token = 1*tchar). */ def "Invalid: Empty parameter name"() { @@ -459,7 +459,7 @@ class WebLinkParserSpec extends Specification { } /** - * Why invalid: Each ";" must be followed by a link-param; ";;" introduces an empty parameter without a token. + * Why invalid: Each ";" must be followed by a link-param; ";;" introduces an withoutValue parameter without a token. * Spec: RFC 8288 section 3, *( OWS ";" OWS link-param ) requires a link-param after each ";". */ def "Invalid: Double semicolon introduces empty parameter"() { @@ -587,8 +587,8 @@ class WebLinkParserSpec extends Specification { } /** - * Why invalid: #link-value requires 1+ elements separated by commas; a leading comma introduces an empty element. - * Spec: RFC 8288 section 3 (Link = #link-value); RFC 7230 section 7 (#rule does not allow empty list elements). + * Why invalid: #link-value requires 1+ elements separated by commas; a leading comma introduces an withoutValue element. + * Spec: RFC 8288 section 3 (Link = #link-value); RFC 7230 section 7 (#rule does not allow withoutValue list elements). */ def "Invalid: Leading comma in Link header list"() { given: @@ -608,8 +608,8 @@ class WebLinkParserSpec extends Specification { } /** - * Why invalid: #link-value requires 1+ elements separated by commas; a trailing comma implies an empty last element. - * Spec: RFC 8288 section 3 (Link = #link-value); RFC 7230 section 7 (#rule does not allow empty list elements). + * Why invalid: #link-value requires 1+ elements separated by commas; a trailing comma implies an withoutValue last element. + * Spec: RFC 8288 section 3 (Link = #link-value); RFC 7230 section 7 (#rule does not allow withoutValue list elements). */ def "Invalid: Trailing comma in Link header list"() { given: diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy index 6309425c8..4a266e224 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy @@ -43,7 +43,7 @@ class Rfc8288ValidatorSpec extends Specification { !result.report().hasErrors() !result.report().hasWarnings() - and: "exactly one WebLink is produced with the expected URI and empty params" + and: "exactly one WebLink is produced with the expected URI and withoutValue params" result.weblinks().size() == 1 WebLink link = result.weblinks().first() link.reference().toString() == "https://example.org/resource" @@ -139,7 +139,7 @@ class Rfc8288ValidatorSpec extends Specification { * deeper interpretation to profile-specific validators (e.g. Signposting). * * How you map "no value" into your RawLink/WebLink model is up to your - * implementation; here we assume null or empty string is used to represent it. + * implementation; here we assume null or withoutValue string is used to represent it. */ def "parameter without value is accepted at RFC level"() { given: From ab661855fb0abd81decf988ae7ce783bb9c63505 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Tue, 25 Nov 2025 12:52:30 +0100 Subject: [PATCH 17/20] Provide more Java Docs --- .../signposting/http/Validator.java | 60 ---------- .../datamanager/signposting/http/WebLink.java | 8 +- .../signposting/http/WebLinkLexer.java | 16 ++- .../signposting/http/WebLinkParser.java | 18 ++- .../http/{lexing => }/WebLinkTokenType.java | 2 +- .../signposting/http/WebLinkValidator.java | 113 ++++++++++++++++++ .../http/lexing/SimpleWebLinkLexer.java | 1 + .../http/lexing/WebLinkLexingException.java | 16 --- .../signposting/http/lexing/WebLinkToken.java | 5 +- .../http/parsing/SimpleWebLinkParser.java | 2 +- ...ator.java => Rfc8288WebLinkValidator.java} | 8 +- .../validation/Rfc8288ValidatorSpec.groovy | 42 +++---- 12 files changed, 177 insertions(+), 114 deletions(-) delete mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/{lexing => }/WebLinkTokenType.java (91%) create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkValidator.java delete mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkLexingException.java rename fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/{Rfc8288Validator.java => Rfc8288WebLinkValidator.java} (95%) diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java deleted file mode 100644 index f50e3b35a..000000000 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/Validator.java +++ /dev/null @@ -1,60 +0,0 @@ -package life.qbic.datamanager.signposting.http; - -import java.util.List; -import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader; - -/** - * - * - * @since - */ -public interface Validator { - - ValidationResult validate(RawLinkHeader rawLinkHeader); - - record ValidationResult(List weblinks, IssueReport report) { - - public boolean containsIssues() { - return !report.isEmpty(); - } - } - - record IssueReport(List issues) { - - public boolean hasErrors() { - return issues.stream().anyMatch(Issue::isError); - } - - public boolean hasWarnings() { - return issues.stream().anyMatch(Issue::isWarning); - } - - public boolean isEmpty() { - return issues.isEmpty(); - } - } - - record Issue(String message, IssueType type) { - - public static Issue warning(String message) { - return new Issue(message, IssueType.WARNING); - } - - public static Issue error(String message) { - return new Issue(message, IssueType.ERROR); - } - - public boolean isWarning() { - return type.equals(IssueType.WARNING); - } - - public boolean isError() { - return type.equals(IssueType.ERROR); - } - } - - enum IssueType { - WARNING, - ERROR - } -} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java index 96c35bc4e..b6411175c 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLink.java @@ -30,12 +30,10 @@ public record WebLink(URI reference, List params) { * @param reference a {@link URI} pointing to the actual resource * @param params a {@link Map} of parameters as keys and a list of their values * @return the new Weblink - * @throws FormatException if the parameters violate any known specification described in the - * RFC * @throws NullPointerException if any method argument is {@code null} */ public static WebLink create(URI reference, List params) - throws FormatException, NullPointerException { + throws NullPointerException { Objects.requireNonNull(reference); Objects.requireNonNull(params); return new WebLink(reference, params); @@ -47,11 +45,9 @@ public static WebLink create(URI reference, List params) * * @param reference a {@link URI} pointing to the actual resource * @return the new Weblink - * @throws FormatException if the parameters violate any known specification described in the - * RFC * @throws NullPointerException if any method argument is {@code null} */ - public static WebLink create(URI reference) throws FormatException, NullPointerException { + public static WebLink create(URI reference) throws NullPointerException { return create(reference, List.of()); } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java index 3a1a7ac14..d1a3e2187 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java @@ -1,7 +1,6 @@ package life.qbic.datamanager.signposting.http; import java.util.List; -import life.qbic.datamanager.signposting.http.lexing.WebLinkLexingException; import life.qbic.datamanager.signposting.http.lexing.WebLinkToken; /** @@ -19,4 +18,19 @@ public interface WebLinkLexer { * @throws WebLinkLexingException if the input is not lexically well-formed */ List lex(String input) throws WebLinkLexingException; + + /** + * Thrown when the input cannot be tokenised according to the Web Link lexical rules. + */ + class WebLinkLexingException extends RuntimeException { + + public WebLinkLexingException(String message) { + super(message); + } + + public WebLinkLexingException(String message, Throwable cause) { + super(message, cause); + } + } + } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java index 659b918fe..007828529 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkParser.java @@ -12,7 +12,7 @@ * link headers after structural validation, which can be seen as an AST (abstract syntax tree). *

* Note: Implementations must not perform semantic validation, this is concern of - * {@link Validator} implementations. + * {@link WebLinkValidator} implementations. *

* In case of structural violations, implementations of the {@link WebLinkParser} interface must * throw a {@link StructureException}. @@ -33,13 +33,27 @@ */ public interface WebLinkParser { + /** + * Parses a list of {@link WebLinkToken} and performs structural validation based on the RFC 8288 + * serialisation requirement. + *

+ * The returned value is an AST of a raw link header with a list of raw web link items that can be + * used for semantic validation. + * + * @param tokens a list of web link tokens to process + * @return a raw link header parsed from the web link tokens + * @throws NullPointerException if the token list is {@code null} + * @throws StructureException if any structural violation occurred + */ RawLinkHeader parse(List tokens) throws NullPointerException, StructureException; + /** + * Indicates a structural violation of the RFC 8288 web link serialisation requirement. + */ class StructureException extends RuntimeException { public StructureException(String message) { super(message); } - } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkTokenType.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkTokenType.java similarity index 91% rename from fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkTokenType.java rename to fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkTokenType.java index ffc1ff996..4b1ac1472 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkTokenType.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkTokenType.java @@ -1,4 +1,4 @@ -package life.qbic.datamanager.signposting.http.lexing; +package life.qbic.datamanager.signposting.http; /** * Enumeration for being used to describe different token types for the diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkValidator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkValidator.java new file mode 100644 index 000000000..f96282e78 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkValidator.java @@ -0,0 +1,113 @@ +package life.qbic.datamanager.signposting.http; + +import java.util.List; +import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader; + +/** + * Performs validation of raw web link headers. + *

+ * Validator are expected to consume output of a {@link WebLinkParser} and convert the web link + * information into reusable web link objects. + *

+ * Implementations of the {@link WebLinkValidator} interface must perform semantic validation only. + *

+ * Implementations also must not interrupt the validation on violations but provide the information + * in the attached {@link IssueReport} of the {@link ValidationResult}. + */ +public interface WebLinkValidator { + + /** + * Validates the given raw link header against the semantic integrity of the validator type. + *

+ * Violations on the semantic level must be recorded in the returned issue list with type + * {@link IssueType#ERROR}. In the presence of any error, at least one web link entry is faulty + * and appropriate error handling is advised. + *

+ * Warnings shall indicate less strict deviations of the specification and must result in usable + * web link objects. If no errors are provided, the client must be able to be safely continue to + * use the web link object in the semantic scope that the validator guarantees. + *

+ * The implementation MUST NOT interrupt the validation in case any error is recorded. Validation + * shall always complete successfully and the method return the validation result. + * + * @param rawLinkHeader the raw link header + * @return the validation result with a list of web link objects and an {@link IssueReport}. + * @throws NullPointerException if the raw link header is {@code null} + */ + ValidationResult validate(RawLinkHeader rawLinkHeader) throws NullPointerException; + + /** + * A summary of the validation with the final web links for further use and an issue report with + * validation warnings or violations. + * + * @param weblinks a collection of web links that have been converted from validation + * @param report a container for recorded issues during validation + */ + record ValidationResult(List weblinks, IssueReport report) { + + public boolean containsIssues() { + return !report.isEmpty(); + } + } + + /** + * A container for recorded issues during validation. + * + * @param issues the issues found during validation + */ + record IssueReport(List issues) { + + public boolean hasErrors() { + return issues.stream().anyMatch(Issue::isError); + } + + public boolean hasWarnings() { + return issues.stream().anyMatch(Issue::isWarning); + } + + public boolean isEmpty() { + return issues.isEmpty(); + } + } + + /** + * Describes any deviations from a semantic model either as warning or error. + * + * @param message a descriptive message that helps clients to process the issue + * @param type the severity level of the issue. {@link IssueType#ERROR} shall be used to + * indicate serious violations from the semantic model that would lead to wrong + * interpretation by the client. For less severe deviations the + * {@link IssueType#WARNING} can be used. + */ + record Issue(String message, IssueType type) { + + public static Issue warning(String message) { + return new Issue(message, IssueType.WARNING); + } + + public static Issue error(String message) { + return new Issue(message, IssueType.ERROR); + } + + public boolean isWarning() { + return type.equals(IssueType.WARNING); + } + + public boolean isError() { + return type.equals(IssueType.ERROR); + } + } + + /** + * An enumeration of different issue types. + * + *

    + *
  • ERROR - Deviation from the semantic level that brakes interpretation, a specification or contract
  • + *
  • WARNING - Deviation from the semantic level that does not brake interpretation, specification or a contract
  • + *
+ */ + enum IssueType { + WARNING, + ERROR + } +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java index 192dbb6f1..8b43641ee 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import life.qbic.datamanager.signposting.http.WebLinkLexer; +import life.qbic.datamanager.signposting.http.WebLinkTokenType; /** * Simple scanning lexer for RFC 8288 Web Link serialisations. diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkLexingException.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkLexingException.java deleted file mode 100644 index 82db7c6a4..000000000 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkLexingException.java +++ /dev/null @@ -1,16 +0,0 @@ -package life.qbic.datamanager.signposting.http.lexing; - - -/** - * Thrown when the input cannot be tokenised according to the Web Link lexical rules. - */ -public class WebLinkLexingException extends RuntimeException { - - public WebLinkLexingException(String message) { - super(message); - } - - public WebLinkLexingException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkToken.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkToken.java index 3de6b18e5..dc6747aae 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkToken.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/WebLinkToken.java @@ -1,5 +1,7 @@ package life.qbic.datamanager.signposting.http.lexing; +import life.qbic.datamanager.signposting.http.WebLinkTokenType; + /** * Single token produced by a WebLinkLexer. * @@ -10,8 +12,7 @@ public record WebLinkToken( WebLinkTokenType type, String text, - int position -) { + int position) { public static WebLinkToken of(WebLinkTokenType type, String text, int position) { return new WebLinkToken(type, text, position); diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java index 0594fcf24..0f526a592 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/parsing/SimpleWebLinkParser.java @@ -7,7 +7,7 @@ import java.util.Objects; import life.qbic.datamanager.signposting.http.WebLinkParser; import life.qbic.datamanager.signposting.http.lexing.WebLinkToken; -import life.qbic.datamanager.signposting.http.lexing.WebLinkTokenType; +import life.qbic.datamanager.signposting.http.WebLinkTokenType; /** * Parses serialized information used in Web Linking as described in * Violations against the specification will be recorded as - * {@link life.qbic.datamanager.signposting.http.Validator.IssueType#ERROR}. In the presence of at + * {@link WebLinkValidator.IssueType#ERROR}. In the presence of at * least one error, the web link MUST be regarded invalid and clients shall not continue to work * with the link, but treat it as exception. *

* The implementation also records issues as - * {@link life.qbic.datamanager.signposting.http.Validator.IssueType#WARNING}, in case the finding + * {@link WebLinkValidator.IssueType#WARNING}, in case the finding * is not strictly against the RFC 8288, but e.g. a type usage is deprecated or when parameters have * been skipped when the specification demands for it. A warning results in a still usable web link, * but it is advised to investigate any findings. * */ -public class Rfc8288Validator implements Validator { +public class Rfc8288WebLinkValidator implements WebLinkValidator { // Defined in https://www.rfc-editor.org/rfc/rfc7230, section 3.2.6 private static final Pattern ALLOWED_TOKEN_CHARS = Pattern.compile( diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy index 4a266e224..24b9cc83b 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/validation/Rfc8288ValidatorSpec.groovy @@ -1,6 +1,6 @@ package life.qbic.datamanager.signposting.http.validation -import life.qbic.datamanager.signposting.http.Validator +import life.qbic.datamanager.signposting.http.WebLinkValidator import life.qbic.datamanager.signposting.http.WebLink import life.qbic.datamanager.signposting.http.parsing.RawLink import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader @@ -8,12 +8,12 @@ import life.qbic.datamanager.signposting.http.parsing.RawParam import spock.lang.Specification /** - * Specification for {@link Rfc8288Validator}. + * Specification for {@link Rfc8288WebLinkValidator}. * * Covers basic RFC 8288 semantics: *

    *
  • Valid URIs create {@link WebLink} instances without issues.
  • - *
  • Invalid URIs create error {@link Validator.Issue}s and no WebLink for that entry.
  • + *
  • Invalid URIs create error {@link WebLinkValidator.Issue}s and no WebLink for that entry.
  • *
  • Multiple links are all validated; one invalid URI does not stop validation.
  • *
  • Unknown / extension parameters are preserved and do not cause issues.
  • *
@@ -33,10 +33,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "no issues are reported" !result.containsIssues() @@ -62,10 +62,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "an error is reported" result.containsIssues() @@ -88,10 +88,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "at least one error is reported for the invalid entry" result.containsIssues() @@ -116,10 +116,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "no errors are reported for unknown parameters" !result.report().hasErrors() @@ -151,10 +151,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "URI is valid, so we get a WebLink back" result.weblinks().size() == 1 @@ -173,10 +173,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "URI is valid, so we get a WebLink back" result.weblinks().size() == 1 @@ -197,10 +197,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "URI is valid, so we get a WebLink back" result.weblinks().size() == 1 @@ -228,10 +228,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "URI is valid, so we get a WebLink back" result.weblinks().size() == 1 @@ -259,10 +259,10 @@ class Rfc8288ValidatorSpec extends Specification { ]) and: - def validator = new Rfc8288Validator() + def validator = new Rfc8288WebLinkValidator() when: - Validator.ValidationResult result = validator.validate(rawHeader) + WebLinkValidator.ValidationResult result = validator.validate(rawHeader) then: "URI is valid, so we get a WebLink back" result.weblinks().size() == 1 From 78d0f02c65d9a5d1392476a01f41649fd748b26f Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Tue, 25 Nov 2025 13:00:48 +0100 Subject: [PATCH 18/20] Provide unit tests for web link lexer implementation --- .../http/lexing/WebLinkLexerSpec.groovy | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/lexing/WebLinkLexerSpec.groovy diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/lexing/WebLinkLexerSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/lexing/WebLinkLexerSpec.groovy new file mode 100644 index 000000000..34b1000f5 --- /dev/null +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/lexing/WebLinkLexerSpec.groovy @@ -0,0 +1,234 @@ +package life.qbic.datamanager.signposting.http.lexing + + +import life.qbic.datamanager.signposting.http.WebLinkLexer +import life.qbic.datamanager.signposting.http.WebLinkLexer.WebLinkLexingException +import life.qbic.datamanager.signposting.http.WebLinkTokenType; +import spock.lang.Specification + +/** + * Specification for a {@link WebLinkLexer} implementation. + * + * These tests verify that a raw Web Link (RFC 8288) serialisation + * is correctly tokenised into a sequence of {@link WebLinkToken}s, + * ending with an EOF token, and that malformed input causes a + * {@link WebLinkLexingException}. + * + */ +class WebLinkLexerSpec extends Specification { + + // Adjust to your concrete implementation + WebLinkLexer lexer = new SimpleWebLinkLexer() + + /** + * Minimal working example: just a URI reference in angle brackets. + * + * ABNF: link-value = "<" URI-Reference ">" *( ...) + */ + def "lexes minimal link with URI only"() { + given: + def input = "" + + when: + def tokens = lexer.lex(input) + + then: "token sequence matches < URI > EOF" + tokens*.type() == [ + WebLinkTokenType.LT, + WebLinkTokenType.URI, + WebLinkTokenType.GT, + WebLinkTokenType.EOF + ] + + and: "URI token text is the raw reference" + tokens[1].text() == "https://example.org/resource" + } + + /** + * Single parameter with a token value. + * + * Example: ; rel=self + */ + def "lexes link with single token parameter"() { + given: + def input = "; rel=self" + + when: + def tokens = lexer.lex(input) + + then: + tokens*.type() == [ + WebLinkTokenType.LT, + WebLinkTokenType.URI, + WebLinkTokenType.GT, + WebLinkTokenType.SEMICOLON, + WebLinkTokenType.IDENT, // rel + WebLinkTokenType.EQUALS, + WebLinkTokenType.IDENT, // self + WebLinkTokenType.EOF + ] + + and: + tokens[1].text() == "https://example.org" + tokens[4].text() == "rel" + tokens[6].text() == "self" + } + + /** + * Single parameter with a quoted-string value. + * + * Example: ; title="A title" + */ + def "lexes link with quoted-string parameter value"() { + given: + def input = '; title="A title"' + + when: + def tokens = lexer.lex(input) + + then: + tokens*.type() == [ + WebLinkTokenType.LT, + WebLinkTokenType.URI, + WebLinkTokenType.GT, + WebLinkTokenType.SEMICOLON, + WebLinkTokenType.IDENT, // title + WebLinkTokenType.EQUALS, + WebLinkTokenType.QUOTED, // "A title" + WebLinkTokenType.EOF + ] + + and: "quoted token text does not contain quotes" + tokens[6].text() == "A title" + } + + /** + * Empty quoted-string is valid: title="". + * + * RFC 7230 §3.2.6 allows zero-length quoted-string. + */ + def "lexes parameter with empty quoted-string value"() { + given: + def input = '; title=""' + + when: + def tokens = lexer.lex(input) + + then: + tokens*.type() == [ + WebLinkTokenType.LT, + WebLinkTokenType.URI, + WebLinkTokenType.GT, + WebLinkTokenType.SEMICOLON, + WebLinkTokenType.IDENT, // title + WebLinkTokenType.EQUALS, + WebLinkTokenType.QUOTED, // "" + WebLinkTokenType.EOF + ] + + and: + tokens[6].text() == "" + } + + /** + * Whitespace (OWS/BWS) must be allowed around separators and '='. + * + * Example: <...> ; rel = "self" + */ + def "ignores optional whitespace around separators and equals"() { + given: + def input = ' ; rel = "self" ' + + when: + def tokens = lexer.lex(input) + + then: "same token sequence as without whitespace" + tokens*.type() == [ + WebLinkTokenType.LT, + WebLinkTokenType.URI, + WebLinkTokenType.GT, + WebLinkTokenType.SEMICOLON, + WebLinkTokenType.IDENT, + WebLinkTokenType.EQUALS, + WebLinkTokenType.QUOTED, + WebLinkTokenType.EOF + ] + + and: + tokens[4].text() == "rel" + tokens[6].text() == "self" + } + + /** + * Multiple link-values separated by a comma at the header field level. + * + * Example:
; rel=self, ; rel=next + * + * The lexer should emit a COMMA token between the two link-values. + */ + def "lexes multiple link-values separated by comma"() { + given: + def input = '; rel=self, ; rel=next' + + when: + def tokens = lexer.lex(input) + + then: + tokens*.type() == [ + WebLinkTokenType.LT, + WebLinkTokenType.URI, + WebLinkTokenType.GT, + WebLinkTokenType.SEMICOLON, + WebLinkTokenType.IDENT, + WebLinkTokenType.EQUALS, + WebLinkTokenType.IDENT, + WebLinkTokenType.COMMA, + WebLinkTokenType.LT, + WebLinkTokenType.URI, + WebLinkTokenType.GT, + WebLinkTokenType.SEMICOLON, + WebLinkTokenType.IDENT, + WebLinkTokenType.EQUALS, + WebLinkTokenType.IDENT, + WebLinkTokenType.EOF + ] + + and: + tokens[1].text() == "https://example.org/a" + tokens[6].text() == "self" + tokens[9].text() == "https://example.org/b" + tokens[14].text() == "next" + } + + /** + * Unterminated quoted-string should be rejected by the lexer. + * + * Example: title="unterminated + */ + def "throws on unterminated quoted string"() { + given: + def input = '; title="unterminated' + + when: + lexer.lex(input) + + then: + thrown(WebLinkLexingException) + } + + /** + * Unterminated URI reference (missing closing '>') should be rejected. + * + * Example: Date: Wed, 26 Nov 2025 09:41:11 +0100 Subject: [PATCH 19/20] Add tests for processor --- .../signposting/http/WebLinkLexer.java | 10 +- .../signposting/http/WebLinkProcessor.java | 107 +++++ .../signposting/http/WebLinkValidator.java | 4 + .../http/lexing/SimpleWebLinkLexer.java | 13 +- .../validation/Rfc8288WebLinkValidator.java | 6 + .../http/WebLinkProcessorSpec.groovy | 400 ++++++++++++++++++ .../http/lexing/WebLinkLexerSpec.groovy | 8 +- 7 files changed, 536 insertions(+), 12 deletions(-) create mode 100644 fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkProcessor.java create mode 100644 fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkProcessorSpec.groovy diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java index d1a3e2187..5e907c862 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkLexer.java @@ -15,20 +15,20 @@ public interface WebLinkLexer { * * @param input the raw Link header field-value or link-value * @return list of tokens ending with an EOF token - * @throws WebLinkLexingException if the input is not lexically well-formed + * @throws LexingException if the input is not lexically well-formed */ - List lex(String input) throws WebLinkLexingException; + List lex(String input) throws LexingException; /** * Thrown when the input cannot be tokenised according to the Web Link lexical rules. */ - class WebLinkLexingException extends RuntimeException { + class LexingException extends RuntimeException { - public WebLinkLexingException(String message) { + public LexingException(String message) { super(message); } - public WebLinkLexingException(String message, Throwable cause) { + public LexingException(String message, Throwable cause) { super(message, cause); } } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkProcessor.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkProcessor.java new file mode 100644 index 000000000..eb3b97577 --- /dev/null +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkProcessor.java @@ -0,0 +1,107 @@ +package life.qbic.datamanager.signposting.http; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import life.qbic.datamanager.signposting.http.WebLinkLexer.LexingException; +import life.qbic.datamanager.signposting.http.WebLinkParser.StructureException; +import life.qbic.datamanager.signposting.http.WebLinkValidator.Issue; +import life.qbic.datamanager.signposting.http.WebLinkValidator.IssueReport; +import life.qbic.datamanager.signposting.http.WebLinkValidator.ValidationResult; +import life.qbic.datamanager.signposting.http.lexing.SimpleWebLinkLexer; +import life.qbic.datamanager.signposting.http.parsing.SimpleWebLinkParser; +import life.qbic.datamanager.signposting.http.validation.Rfc8288WebLinkValidator; + +/** + * + * + * @since + */ +public class WebLinkProcessor { + + private final WebLinkLexer lexer; + private final WebLinkParser parser; + private final List validators; + + private WebLinkProcessor() { + this.lexer = null; + this.parser = null; + this.validators = null; + } + + private WebLinkProcessor( + WebLinkLexer selectedLexer, + WebLinkParser selectedParser, + List selectedValidators) { + this.lexer = Objects.requireNonNull(selectedLexer); + this.parser = Objects.requireNonNull(selectedParser); + this.validators = List.copyOf(Objects.requireNonNull(selectedValidators)); + } + + public ValidationResult process(String rawLinkHeader) + throws LexingException, StructureException, NullPointerException { + var header = Objects.requireNonNull(rawLinkHeader); + var tokenizedHeader = lexer.lex(header); + var parsedHeader = parser.parse(tokenizedHeader); + + var aggregatedIssues = new ArrayList(); + ValidationResult cachedValidationResult = null; + for (WebLinkValidator validator : validators) { + cachedValidationResult = validator.validate(parsedHeader); + aggregatedIssues.addAll(cachedValidationResult.report().issues()); + } + + if (cachedValidationResult == null) { + throw new IllegalStateException( + "No validation result was found after processing: " + rawLinkHeader); + } + + return new ValidationResult(cachedValidationResult.weblinks(), + new IssueReport(aggregatedIssues)); + } + + public static class Builder { + + private WebLinkLexer configuredLexer; + + private WebLinkParser configuredParser; + + private final List configuredValidators = new ArrayList<>(); + + public Builder withLexer(WebLinkLexer lexer) { + configuredLexer = lexer; + return this; + } + + public Builder withParser(WebLinkParser parser) { + configuredParser = parser; + return this; + } + + public Builder withValidator(WebLinkValidator validator) { + configuredValidators.add(validator); + return this; + } + + public WebLinkProcessor build() { + var selectedLexer = configuredLexer == null ? defaultLexer() : configuredLexer; + var selectedParser = configuredParser == null ? defaultParser() : configuredParser; + var selectedValidators = + configuredValidators.isEmpty() ? List.of(defaultValidator()) : configuredValidators; + + return new WebLinkProcessor(selectedLexer, selectedParser, selectedValidators); + } + + private WebLinkParser defaultParser() { + return SimpleWebLinkParser.create(); + } + + private static WebLinkLexer defaultLexer() { + return SimpleWebLinkLexer.create(); + } + + private static WebLinkValidator defaultValidator() { + return Rfc8288WebLinkValidator.create(); + } + } +} diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkValidator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkValidator.java index f96282e78..9b5f141d1 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkValidator.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkValidator.java @@ -45,6 +45,10 @@ public interface WebLinkValidator { */ record ValidationResult(List weblinks, IssueReport report) { + public ValidationResult { + weblinks = List.copyOf(weblinks); + } + public boolean containsIssues() { return !report.isEmpty(); } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java index 8b43641ee..4247fbaa5 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/lexing/SimpleWebLinkLexer.java @@ -21,8 +21,15 @@ */ public final class SimpleWebLinkLexer implements WebLinkLexer { + private SimpleWebLinkLexer() {} + + public static SimpleWebLinkLexer create() { + return new SimpleWebLinkLexer(); + } + + @Override - public List lex(String input) throws WebLinkLexingException { + public List lex(String input) throws LexingException { return new Scanner(input).scan(); } @@ -99,7 +106,7 @@ private void readUri(int start) { } if (eof()) { - throw new WebLinkLexingException( + throw new LexingException( "Unterminated URI reference: missing '>' for '<' at position " + start); } @@ -132,7 +139,7 @@ private void readQuoted(int start) { } if (eof()) { - throw new WebLinkLexingException( + throw new LexingException( "Unterminated quoted-string starting at position " + start); } diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288WebLinkValidator.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288WebLinkValidator.java index 1863b523f..ce2115cfc 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288WebLinkValidator.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/validation/Rfc8288WebLinkValidator.java @@ -34,6 +34,12 @@ public class Rfc8288WebLinkValidator implements WebLinkValidator { private static final Pattern ALLOWED_TOKEN_CHARS = Pattern.compile( "^[!#$%&'*+-.^_`|~0-9A-Za-z]+$"); + private Rfc8288WebLinkValidator() {} + + public static WebLinkValidator create() { + return new Rfc8288WebLinkValidator(); + } + @Override public ValidationResult validate(RawLinkHeader rawLinkHeader) { var recordedIssues = new ArrayList(); diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkProcessorSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkProcessorSpec.groovy new file mode 100644 index 000000000..0932d90a0 --- /dev/null +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/WebLinkProcessorSpec.groovy @@ -0,0 +1,400 @@ +package life.qbic.datamanager.signposting.http + +import life.qbic.datamanager.signposting.http.WebLinkLexer.LexingException +import life.qbic.datamanager.signposting.http.lexing.WebLinkToken +import life.qbic.datamanager.signposting.http.parsing.RawLinkHeader +import life.qbic.datamanager.signposting.http.WebLinkValidator.Issue +import life.qbic.datamanager.signposting.http.WebLinkValidator.IssueReport +import life.qbic.datamanager.signposting.http.WebLinkValidator.ValidationResult +import spock.lang.Specification +import spock.lang.Unroll + +class WebLinkProcessorSpec extends Specification { + + // --------------------------------------------------------------------------- + // Helpers – ADAPT CONSTRUCTORS HERE + // --------------------------------------------------------------------------- + + /** + * Create a minimal but real WebLinkToken list. + * + */ + static List dummyTokens() { + return List.of( + new WebLinkToken(WebLinkTokenType.URI, "https://example.org", 0) + ) + } + + /** + * Create a minimal but real RawLinkHeader. + * Adjust constructor to your actual RawLinkHeader definition. + * + * Example assumption: + * public record RawLinkHeader(List rawLinks) { } + */ + static RawLinkHeader dummyParsedHeader() { + return new RawLinkHeader(List.of()) + } + + /** + * Create a minimal but real WebLink instance. + * Adjust constructor to your actual WebLink record/class. + * + * Example assumption: + * public record WebLink(URI reference, Map parameters) { } + */ + static WebLink dummyWebLink(String id) { + return new WebLink( + URI.create("https://example.org/" + id), + List.of() + ) + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + def "default processor can process minimal valid link header"() { + given: + def processor = new WebLinkProcessor.Builder().build() + def input = "" + + when: + def result = processor.process(input) + + then: + result != null + result.weblinks() != null + result.report() != null + } + + /** + * When a custom lexer is provided, it must be used instead of the default one. + */ + def "processor uses configured lexer instead of default"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + def validator = Mock(WebLinkValidator) + + def tokens = dummyTokens() + def parsedHeader = dummyParsedHeader() + def validationResult = new ValidationResult(List.of(), new IssueReport(List.of())) + + and: + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .withValidator(validator) + .build() + + when: + def result = processor.process("") + + then: + 1 * lexer.lex("") >> tokens + 1 * parser.parse(tokens) >> parsedHeader + 1 * validator.validate(parsedHeader) >> validationResult + + and: + result.weblinks().isEmpty() + !result.report().hasErrors() + } + + /** + * When a custom parser is provided, it must be used instead of the default one. + */ + def "processor uses configured parser instead of default"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + def validator = Mock(WebLinkValidator) + + def tokens = dummyTokens() + def parsedHeader = dummyParsedHeader() + def validationResult = new ValidationResult(List.of(), new IssueReport(List.of())) + + and: + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .withValidator(validator) + .build() + + when: + def result = processor.process("") + + then: + 1 * lexer.lex("") >> tokens + 1 * parser.parse(tokens) >> parsedHeader + 1 * validator.validate(parsedHeader) >> validationResult + + and: + result != null + } + + def "builder injects default validator when none configured"() { + given: + def processor = new WebLinkProcessor.Builder().build() + def input = "" + + when: + def result = processor.process(input) + + then: + result != null + result.weblinks() != null + result.report() != null + } + + def "aggregates issues from multiple validators and uses last validator's weblinks"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + def validator1 = Mock(WebLinkValidator) + def validator2 = Mock(WebLinkValidator) + + def tokens = dummyTokens() + def parsedHeader = dummyParsedHeader() + + def link1 = dummyWebLink("v1") + def link2 = dummyWebLink("v2") + + def issue1 = Issue.error("first") + def issue2 = Issue.warning("second") + + def result1 = new ValidationResult(List.of(link1), new IssueReport(List.of(issue1))) + def result2 = new ValidationResult(List.of(link2), new IssueReport(List.of(issue2))) + + and: + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .withValidator(validator1) + .withValidator(validator2) + .build() + + when: + def result = processor.process("") + + then: + 1 * lexer.lex("") >> tokens + 1 * parser.parse(tokens) >> parsedHeader + 1 * validator1.validate(parsedHeader) >> result1 + 1 * validator2.validate(parsedHeader) >> result2 + + and: + result.weblinks() == List.of(link2) + result.report().issues().containsAll(List.of(issue1, issue2)) + result.report().issues().size() == 2 + } + + @Unroll + def "process throws NullPointerException for null input (#caseName)"() { + given: + def processor = new WebLinkProcessor.Builder().build() + + when: + processor.process(input) + + then: + thrown(NullPointerException) + + where: + caseName | input + "null header" | null + } + + def "lexer exception is propagated and prevents parser and validators from running"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + def validator = Mock(WebLinkValidator) + + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .withValidator(validator) + .build() + + when: + processor.process("> { throw new LexingException("boom") } + 0 * parser._ + 0 * validator._ + + and: + thrown(LexingException) + } + + def "parser exception is propagated and prevents validators from running"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + def validator = Mock(WebLinkValidator) + + def tokens = dummyTokens() + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .withValidator(validator) + .build() + + when: + processor.process("") + + then: + 1 * lexer.lex("") >> tokens + 1 * parser.parse(tokens) >> { throw new RuntimeException("parse error") } + 0 * validator._ + + and: + thrown(RuntimeException) + } + + def "validator exception is propagated and stops further validators"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + def validator1 = Mock(WebLinkValidator) + def validator2 = Mock(WebLinkValidator) + + def tokens = dummyTokens() + def parsedHeader = dummyParsedHeader() + + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .withValidator(validator1) + .withValidator(validator2) + .build() + + when: + processor.process("
") + + then: + 1 * lexer.lex("
") >> tokens + 1 * parser.parse(tokens) >> parsedHeader + 1 * validator1.validate(parsedHeader) >> { throw new RuntimeException("validator boom") } + 0 * validator2._ + + and: + thrown(RuntimeException) + } + + def "throws IllegalStateException when no validator produces a result (defensive branch)"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + + def tokens = dummyTokens() + def parsedHeader = dummyParsedHeader() + + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .build() + + and: + def validatorsField = WebLinkProcessor.getDeclaredField("validators") + validatorsField.accessible = true + validatorsField.set(processor, List.of()) // simulate broken internal state + + when: + processor.process("") + + then: + 1 * lexer.lex("") >> tokens + 1 * parser.parse(tokens) >> parsedHeader + + and: + def ex = thrown(IllegalStateException) + ex.message.contains("No validation result was found") + } + + def "external mutation of issue list from validator does not break aggregated result"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + def validator = Mock(WebLinkValidator) + + def tokens = dummyTokens() + def parsedHeader = dummyParsedHeader() + + def mutableIssues = new ArrayList() + mutableIssues.add(Issue.error("original")) + + def validationResult = new ValidationResult( + List.of(dummyWebLink("l1")), + new IssueReport(mutableIssues) + ) + + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .withValidator(validator) + .build() + + when: + def result = processor.process("") + + then: + 1 * lexer.lex("") >> tokens + 1 * parser.parse(tokens) >> parsedHeader + 1 * validator.validate(parsedHeader) >> validationResult + + and: + result.report().issues().size() == 1 + + when: + mutableIssues.clear() + + then: + result.report().issues().size() == 1 + } + + def "external mutation of weblink list from validator does not alter processor result"() { + given: + def lexer = Mock(WebLinkLexer) + def parser = Mock(WebLinkParser) + def validator = Mock(WebLinkValidator) + + def tokens = dummyTokens() + def parsedHeader = dummyParsedHeader() + + def mutableWebLinks = new ArrayList() + def link = dummyWebLink("foo") + mutableWebLinks.add(link) + + def validationResult = new ValidationResult( + mutableWebLinks, + new IssueReport(List.of()) + ) + + def processor = new WebLinkProcessor.Builder() + .withLexer(lexer) + .withParser(parser) + .withValidator(validator) + .build() + + when: + def result = processor.process("") + + then: + 1 * lexer.lex("") >> tokens + 1 * parser.parse(tokens) >> parsedHeader + 1 * validator.validate(parsedHeader) >> validationResult + + and: + result.weblinks().size() == 1 + result.weblinks().first() == link + + when: + mutableWebLinks.clear() + + then: + result.weblinks().size() == 1 + } +} diff --git a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/lexing/WebLinkLexerSpec.groovy b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/lexing/WebLinkLexerSpec.groovy index 34b1000f5..c40b3a8a1 100644 --- a/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/lexing/WebLinkLexerSpec.groovy +++ b/fair-signposting/src/test/groovy/life/qbic/datamanager/signposting/http/lexing/WebLinkLexerSpec.groovy @@ -2,7 +2,7 @@ package life.qbic.datamanager.signposting.http.lexing import life.qbic.datamanager.signposting.http.WebLinkLexer -import life.qbic.datamanager.signposting.http.WebLinkLexer.WebLinkLexingException +import life.qbic.datamanager.signposting.http.WebLinkLexer.LexingException import life.qbic.datamanager.signposting.http.WebLinkTokenType; import spock.lang.Specification @@ -12,7 +12,7 @@ import spock.lang.Specification * These tests verify that a raw Web Link (RFC 8288) serialisation * is correctly tokenised into a sequence of {@link WebLinkToken}s, * ending with an EOF token, and that malformed input causes a - * {@link WebLinkLexingException}. + * {@link LexingException}. * */ class WebLinkLexerSpec extends Specification { @@ -213,7 +213,7 @@ class WebLinkLexerSpec extends Specification { lexer.lex(input) then: - thrown(WebLinkLexingException) + thrown(LexingException) } /** @@ -229,6 +229,6 @@ class WebLinkLexerSpec extends Specification { lexer.lex(input) then: - thrown(WebLinkLexingException) + thrown(LexingException) } } From 691ad0434d384be7ba8feb378172689413ceb98c Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Thu, 27 Nov 2025 09:37:13 +0100 Subject: [PATCH 20/20] Javadocs for the processor --- .../signposting/http/WebLinkProcessor.java | 107 +++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkProcessor.java b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkProcessor.java index eb3b97577..9aa524ede 100644 --- a/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkProcessor.java +++ b/fair-signposting/src/main/java/life/qbic/datamanager/signposting/http/WebLinkProcessor.java @@ -13,9 +13,10 @@ import life.qbic.datamanager.signposting.http.validation.Rfc8288WebLinkValidator; /** - * + * Configurable processor for raw web link strings from the HTTP Link header field. + *

+ * The underlying standard is RFC 8288 * - * @since */ public class WebLinkProcessor { @@ -38,6 +39,33 @@ private WebLinkProcessor( this.validators = List.copyOf(Objects.requireNonNull(selectedValidators)); } + /** + * Processes a raw link header string and returns a validation result with the final web links and + * an issue report. + *

+ * The processor performs different steps until the validation result returns: + * + *

    + *
  1. Tokenization: the raw string gets translated into enumerated token values
  2. + *
  3. Parsing: the token collection gets structurally parsed and checked, the result is an AST of raw link values
  4. + *
  5. Validation: one or more validation steps to semantically check the raw web links
  6. + *
+ *

+ * The caller is advised to check the {@link ValidationResult#report()} in case issues have been recorded. + *

+ * By contract of the validation interface, validators MUST record issues as errors in case there are severe semantically + * deviations from the model the validator represents. Warnings can be investigated, but clients + * can expect to continue to use the returned web links. + * + * @param rawLinkHeader the serialized raw link header value + * @return a validation result with the web links and an issue report with recorded findings of + * warnings and errors. + * @throws LexingException in case the header contains invalid characters (during + * tokenizing) + * @throws StructureException in case the header does not have the expected structure (during + * parsing) + * @throws NullPointerException in case the raw link header is {@code null} + */ public ValidationResult process(String rawLinkHeader) throws LexingException, StructureException, NullPointerException { var header = Objects.requireNonNull(rawLinkHeader); @@ -60,6 +88,47 @@ public ValidationResult process(String rawLinkHeader) new IssueReport(aggregatedIssues)); } + /** + * Builder for a {@link WebLinkProcessor}. + *

+ * The builder allows for flexible configuration of the different processing steps: + * + *

    + *
  1. Tokenization: the raw string gets translated into enumerated token values
  2. + *
  3. Parsing: the token collection gets structurally parsed and checked, the result is an AST of raw link values
  4. + *
  5. Validation: one or more validation steps to semantically check the raw web links
  6. + *
+ *

+ * It is possible to create a default processor by simply omitting any configuration: + * + *

+   *   {@code
+   *   // Creates a processor with default configuration
+   *   WebLinkProcessor defaultProcessor = new Builder.build()
+   *   }
+   * 
+ *

+ * The default components are: + * + *

    + *
  • lexer: {@link SimpleWebLinkLexer}
  • + *
  • parser: {@link SimpleWebLinkParser}
  • + *
  • validator: {@link Rfc8288WebLinkValidator}
  • + *
+ * + * The RFC 8282 validator will only be used if no validator has been provided. If you want + * to combine the RFC validator with additional ones, you can do so: + * + *
+   *   {@code
+   *
+   *   WebLinkProcessor customProcessor =
+   *      new Builder.withValidator(Rfc8288WebLinkValidator.create())
+   *                 .withValidator(new MyCustomValidator())
+   *                 .build()
+   *   }
+   * 
+ */ public static class Builder { private WebLinkLexer configuredLexer; @@ -68,21 +137,55 @@ public static class Builder { private final List configuredValidators = new ArrayList<>(); + /** + * Configures a different lexer from the default that shall be used in the processing. + * + * @param lexer the lexer to be used in the processing + * @return the builder instance + */ public Builder withLexer(WebLinkLexer lexer) { configuredLexer = lexer; return this; } + /** + * Configures a different lexer from the default that shall be used in the processing. + * + * @param lexer the lexer to be used in the processing + * @return the builder instance + */ public Builder withParser(WebLinkParser parser) { configuredParser = parser; return this; } + /** + * Configures a different lexer from the default that shall be used in the processing. + *

+ * Multiple validators can be configured by calling this method repeatedly. The validators are + * called in the order they have been configured on the builder. + * + *

+     *   {@code
+     *   var processor = Builder.withValidator(first)  // first validator
+     *                          .withValidator(other)  // appends next validator
+     *                          .build()
+     *   }
+     * 
+ * + * @param validator the validator to be used in the processing + * @return the builder instance + */ public Builder withValidator(WebLinkValidator validator) { configuredValidators.add(validator); return this; } + /** + * Creates instance of a web link processor object based on the configuration. + * + * @return the configured web link processor + */ public WebLinkProcessor build() { var selectedLexer = configuredLexer == null ? defaultLexer() : configuredLexer; var selectedParser = configuredParser == null ? defaultParser() : configuredParser;