From 11960477d66719180e8623990974d20d9cfd0189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20F=C4=85derski?= Date: Wed, 12 Aug 2026 16:59:24 +0200 Subject: [PATCH 01/13] Initial version of schema version displaying --- docker/latest/management/management.yaml | 6 ++ .../tech/hermes/api/TopicWithSchema.java | 66 +++++++++++++- .../tech/hermes/api/TopicWithSchemaTest.java | 38 ++++++++ hermes-console/src/api/app-configuration.ts | 1 + hermes-console/src/api/topic.ts | 2 + hermes-console/src/dummy/app-config.ts | 1 + hermes-console/src/dummy/topic.ts | 2 + hermes-console/src/i18n/en-US/index.ts | 4 + hermes-console/src/views/topic/TopicView.vue | 12 ++- .../topic/schema-panel/SchemaPanel.spec.ts | 87 ++++++++++++++++++- .../views/topic/schema-panel/SchemaPanel.vue | 79 ++++++++++++++++- .../config/console/ConsoleConfiguration.java | 9 ++ .../config/console/ConsoleProperties.java | 9 ++ .../management/domain/topic/TopicService.java | 19 ++-- .../domain/topic/schema/SchemaService.java | 20 ++++- .../src/main/resources/application-local.yaml | 3 + .../console/ConsoleConfigurationSpec.groovy | 36 ++++++++ .../domain/topic/TopicServiceSpec.groovy | 66 ++++++++++++++ .../topic/schema/SchemaServiceSpec.groovy | 28 ++++++ .../management/TopicManagementTest.java | 39 +++++++++ 20 files changed, 512 insertions(+), 15 deletions(-) create mode 100644 hermes-api/src/test/java/pl/allegro/tech/hermes/api/TopicWithSchemaTest.java create mode 100644 hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/domain/topic/TopicServiceSpec.groovy create mode 100644 hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/domain/topic/schema/SchemaServiceSpec.groovy diff --git a/docker/latest/management/management.yaml b/docker/latest/management/management.yaml index 3fa20ad1a5..a629916fca 100644 --- a/docker/latest/management/management.yaml +++ b/docker/latest/management/management.yaml @@ -4,7 +4,13 @@ spring: filter: order: 2147483647 +server: + port: 8090 + management: + console: + topic: + schemaRegistryUrl: http://localhost:8081 zookeeper: clusters: - datacenter: dc diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicWithSchema.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicWithSchema.java index c2e3e1c7ed..b04c7bae6d 100644 --- a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicWithSchema.java +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicWithSchema.java @@ -3,9 +3,11 @@ import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.OptBoolean; import java.time.Instant; +import java.util.List; import java.util.Objects; public class TopicWithSchema extends Topic { @@ -14,6 +16,10 @@ public class TopicWithSchema extends Topic { private final String schema; + private final Integer schemaVersion; + + private final List availableSchemaVersions; + public TopicWithSchema(Topic topic, String schema) { this( schema, @@ -33,7 +39,37 @@ public TopicWithSchema(Topic topic, String schema) { topic.isSubscribingRestricted(), topic.getOfflineStorage(), topic.getCreatedAt(), - topic.getModifiedAt()); + topic.getModifiedAt(), + null, + null); + } + + public TopicWithSchema( + Topic topic, + String schema, + Integer schemaVersion, + List availableSchemaVersions) { + this( + schema, + topic.getQualifiedName(), + topic.getDescription(), + topic.getOwner(), + topic.getRetentionTime(), + topic.isJsonToAvroDryRunEnabled(), + topic.getAck(), + topic.isFallbackToRemoteDatacenterEnabled(), + topic.getChaos(), + topic.isTrackingEnabled(), + topic.wasMigratedFromJsonType(), + topic.getContentType(), + topic.getMaxMessageSize(), + topic.getPublishingAuth(), + topic.isSubscribingRestricted(), + topic.getOfflineStorage(), + topic.getCreatedAt(), + topic.getModifiedAt(), + schemaVersion, + availableSchemaVersions); } @JsonCreator @@ -59,7 +95,9 @@ public TopicWithSchema( @JsonProperty("subscribingRestricted") boolean subscribingRestricted, @JsonProperty("offlineStorage") TopicDataOfflineStorage offlineStorage, @JsonProperty("createdAt") Instant createdAt, - @JsonProperty("modifiedAt") Instant modifiedAt) { + @JsonProperty("modifiedAt") Instant modifiedAt, + @JsonProperty("schemaVersion") Integer schemaVersion, + @JsonProperty("availableSchemaVersions") List availableSchemaVersions) { super( qualifiedName, description, @@ -80,12 +118,19 @@ public TopicWithSchema( modifiedAt); this.topic = convertToTopic(); this.schema = schema; + this.schemaVersion = schemaVersion; + this.availableSchemaVersions = availableSchemaVersions; } public static TopicWithSchema topicWithSchema(Topic topic, String schema) { return new TopicWithSchema(topic, schema); } + public static TopicWithSchema topicWithSchema( + Topic topic, String schema, Integer schemaVersion, List availableSchemaVersions) { + return new TopicWithSchema(topic, schema, schemaVersion, availableSchemaVersions); + } + public static TopicWithSchema topicWithSchema(Topic topic) { return new TopicWithSchema(topic, null); } @@ -115,6 +160,16 @@ public String getSchema() { return schema; } + @JsonInclude(JsonInclude.Include.NON_NULL) + public Integer getSchemaVersion() { + return schemaVersion; + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public List getAvailableSchemaVersions() { + return availableSchemaVersions; + } + @JsonIgnore public Topic getTopic() { return topic; @@ -132,11 +187,14 @@ public boolean equals(Object o) { return false; } TopicWithSchema that = (TopicWithSchema) o; - return Objects.equals(topic, that.topic) && Objects.equals(schema, that.schema); + return Objects.equals(topic, that.topic) + && Objects.equals(schema, that.schema) + && Objects.equals(schemaVersion, that.schemaVersion) + && Objects.equals(availableSchemaVersions, that.availableSchemaVersions); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), topic, schema); + return Objects.hash(super.hashCode(), topic, schema, schemaVersion, availableSchemaVersions); } } diff --git a/hermes-api/src/test/java/pl/allegro/tech/hermes/api/TopicWithSchemaTest.java b/hermes-api/src/test/java/pl/allegro/tech/hermes/api/TopicWithSchemaTest.java new file mode 100644 index 0000000000..e4e13b19af --- /dev/null +++ b/hermes-api/src/test/java/pl/allegro/tech/hermes/api/TopicWithSchemaTest.java @@ -0,0 +1,38 @@ +package pl.allegro.tech.hermes.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.Test; +import pl.allegro.tech.hermes.test.helper.builder.TopicBuilder; + +public class TopicWithSchemaTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void shouldSerializeSchemaVersionMetadataWhenPresent() throws Exception { + Topic topic = TopicBuilder.topic("group.topic").build(); + TopicWithSchema topicWithSchema = + TopicWithSchema.topicWithSchema(topic, "schema", 3, List.of(1, 2, 3)); + + String serialized = objectMapper.writeValueAsString(topicWithSchema); + + assertThat(serialized) + .contains("\"schemaVersion\":3", "\"availableSchemaVersions\":[1,2,3]"); + } + + @Test + public void shouldOmitAbsentOrEmptySchemaVersionMetadata() throws Exception { + Topic topic = TopicBuilder.topic("group.topic").build(); + + String absent = objectMapper.writeValueAsString(TopicWithSchema.topicWithSchema(topic, "schema")); + String empty = + objectMapper.writeValueAsString( + TopicWithSchema.topicWithSchema(topic, "schema", null, List.of())); + + assertThat(absent).doesNotContain("schemaVersion", "availableSchemaVersions"); + assertThat(empty).doesNotContain("schemaVersion", "availableSchemaVersions"); + } +} diff --git a/hermes-console/src/api/app-configuration.ts b/hermes-console/src/api/app-configuration.ts index 7a302c9f18..4d7b54f235 100644 --- a/hermes-console/src/api/app-configuration.ts +++ b/hermes-console/src/api/app-configuration.ts @@ -58,6 +58,7 @@ export interface OwnerSourceConfiguration { export interface TopicViewConfiguration { messagePreviewEnabled: boolean; offlineClientsEnabled: boolean; + schemaRegistryUrl?: string; defaults: DefaultTopicViewConfiguration; contentTypes: TopicContentType[]; readOnlyModeEnabled: boolean; diff --git a/hermes-console/src/api/topic.ts b/hermes-console/src/api/topic.ts index 503fa4ee9f..3e532a978d 100644 --- a/hermes-console/src/api/topic.ts +++ b/hermes-console/src/api/topic.ts @@ -4,6 +4,8 @@ import type { OwnerId } from '@/api/owner-id'; export interface TopicWithSchema extends Topic { schema: string; + schemaVersion?: number; + availableSchemaVersions?: number[]; } export interface Topic { diff --git a/hermes-console/src/dummy/app-config.ts b/hermes-console/src/dummy/app-config.ts index 934ad46147..df1756f746 100644 --- a/hermes-console/src/dummy/app-config.ts +++ b/hermes-console/src/dummy/app-config.ts @@ -35,6 +35,7 @@ export const dummyAppConfig: AppConfiguration = { topic: { messagePreviewEnabled: true, offlineClientsEnabled: true, + schemaRegistryUrl: 'https://schema-registry.example.com', defaults: { ack: 'LEADER', contentType: 'AVRO', diff --git a/hermes-console/src/dummy/topic.ts b/hermes-console/src/dummy/topic.ts index 9ddec8ad96..4bf097f0a6 100644 --- a/hermes-console/src/dummy/topic.ts +++ b/hermes-console/src/dummy/topic.ts @@ -45,6 +45,8 @@ export const dummyTopic: TopicWithSchema = { }, createdAt: 1634916242.877, modifiedAt: 1636451113.517, + schemaVersion: 2, + availableSchemaVersions: [2, 1], }; export const dummyOwner: Owner = { diff --git a/hermes-console/src/i18n/en-US/index.ts b/hermes-console/src/i18n/en-US/index.ts index 5786056b0a..c8e34118f5 100644 --- a/hermes-console/src/i18n/en-US/index.ts +++ b/hermes-console/src/i18n/en-US/index.ts @@ -503,9 +503,13 @@ const en_US = { title: 'Offline clients', }, schema: { + activeVersion: 'Active version: {version}', + allVersions: 'All versions ({count})', copy: 'Copy to clipboard', + current: 'Current', default: 'Default', rawSchema: 'Raw schema', + notApplicable: 'N/A (JSON topic)', structure: 'Structure', showRawSchema: 'Show raw schema', title: 'Message schema', diff --git a/hermes-console/src/views/topic/TopicView.vue b/hermes-console/src/views/topic/TopicView.vue index 366ea5325f..8dda939890 100644 --- a/hermes-console/src/views/topic/TopicView.vue +++ b/hermes-console/src/views/topic/TopicView.vue @@ -219,7 +219,17 @@ - + diff --git a/hermes-console/src/views/topic/schema-panel/SchemaPanel.spec.ts b/hermes-console/src/views/topic/schema-panel/SchemaPanel.spec.ts index 5106dd6d09..d164f39f82 100644 --- a/hermes-console/src/views/topic/schema-panel/SchemaPanel.spec.ts +++ b/hermes-console/src/views/topic/schema-panel/SchemaPanel.spec.ts @@ -1,3 +1,4 @@ +import { ContentType } from '@/api/content-type'; import { describe, expect } from 'vitest'; import { dummyTopic } from '@/dummy/topic'; import { render } from '@/utils/test-utils'; @@ -5,7 +6,11 @@ import SchemaPanel from '@/views/topic/schema-panel/SchemaPanel.vue'; import userEvent from '@testing-library/user-event'; describe('SchemaPanel', () => { - const props = { schema: dummyTopic.schema }; + const props = { + schema: dummyTopic.schema, + contentType: ContentType.AVRO, + topicName: dummyTopic.name, + }; it('should render avro formatted schema by default', async () => { // given @@ -45,4 +50,84 @@ describe('SchemaPanel', () => { // then expect(codeElement).toBeVisible(); }); + + it('should show sorted, linked schema version history and current version', async () => { + const { getByText, getByRole } = render(SchemaPanel, { + props: { + ...props, + schemaVersion: 2, + availableSchemaVersions: [1, 3, 2], + schemaRegistryUrl: 'https://schema-registry.example.com/', + topicName: 'group/topic name', + }, + }); + + expect( + getByText('topicView.schema.activeVersion', { exact: false }), + ).toBeVisible(); + await userEvent.click( + getByText('topicView.schema.allVersions', { exact: false }), + ); + + const links = getByRole('list').querySelectorAll('a'); + expect([...links].map((link) => link.textContent)).toEqual(['3', '2', '1']); + expect(links[1]).toHaveAttribute( + 'href', + 'https://schema-registry.example.com/subjects/group%2Ftopic%20name-value/versions/2', + ); + expect(links[1]).toHaveAttribute('target', '_blank'); + expect(links[1]).toHaveAttribute('rel', 'noopener noreferrer'); + expect(getByText('topicView.schema.current')).toBeVisible(); + }); + + it('should show only the active version without registry URL or history', () => { + const { getByText, queryByText } = render(SchemaPanel, { + props: { ...props, schemaVersion: 2 }, + }); + + expect( + getByText('topicView.schema.activeVersion', { exact: false }), + ).toBeVisible(); + expect( + queryByText('topicView.schema.allVersions', { exact: false }), + ).not.toBeInTheDocument(); + }); + + it('should show JSON topics as not applicable', () => { + const { getByText, queryByText } = render(SchemaPanel, { + props: { + ...props, + contentType: ContentType.JSON, + schemaVersion: 2, + availableSchemaVersions: [1, 2], + schemaRegistryUrl: 'https://schema-registry.example.com', + }, + }); + + expect(getByText('topicView.schema.notApplicable')).toBeVisible(); + expect( + queryByText('topicView.schema.allVersions', { exact: false }), + ).not.toBeInTheDocument(); + }); + + it('should cap long version histories with scrolling', async () => { + const { getByText, getByTestId } = render(SchemaPanel, { + props: { + ...props, + availableSchemaVersions: Array.from( + { length: 11 }, + (_, index) => index + 1, + ), + schemaRegistryUrl: 'https://schema-registry.example.com', + }, + }); + + await userEvent.click( + getByText('topicView.schema.allVersions', { exact: false }), + ); + + expect(getByTestId('schema-version-history')).toHaveClass( + 'schema-version-history', + ); + }); }); diff --git a/hermes-console/src/views/topic/schema-panel/SchemaPanel.vue b/hermes-console/src/views/topic/schema-panel/SchemaPanel.vue index b6940710aa..b9facd9fbf 100644 --- a/hermes-console/src/views/topic/schema-panel/SchemaPanel.vue +++ b/hermes-console/src/views/topic/schema-panel/SchemaPanel.vue @@ -1,13 +1,40 @@ + + diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/console/ConsoleConfiguration.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/console/ConsoleConfiguration.java index f7fd5ccb8b..76c6d8043b 100644 --- a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/console/ConsoleConfiguration.java +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/console/ConsoleConfiguration.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; @@ -16,6 +18,7 @@ @Configuration @EnableConfigurationProperties(ConsoleProperties.class) public class ConsoleConfiguration { + private static final Logger logger = LoggerFactory.getLogger(ConsoleConfiguration.class); @Bean FilterRegistrationBean frontendRoutesFilter() { @@ -31,6 +34,12 @@ ConsoleConfigurationRepository consoleConfigurationRepository( GroupProperties groupProperties, TopicProperties topicProperties) { + if (consoleProperties.getTopic().getSchemaRegistryUrl() == null + || consoleProperties.getTopic().getSchemaRegistryUrl().isBlank()) { + logger.warn( + "Console topic schemaRegistryUrl is not configured. Schema versions will not link to Schema Registry."); + } + // Override group settings from GroupProperties (source of truth) // Note: console.group.nonAdminCreationEnabled is IGNORED if configured in application.yaml // See JavaDoc on ConsoleProperties.GroupView for details diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/console/ConsoleProperties.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/console/ConsoleProperties.java index a4fa10eb18..78aa67d80d 100644 --- a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/console/ConsoleProperties.java +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/console/ConsoleProperties.java @@ -295,6 +295,7 @@ public void setScope(String scope) { public static final class TopicView { private boolean messagePreviewEnabled = true; private boolean offlineClientsEnabled = false; + private String schemaRegistryUrl; private DefaultTopicView defaults = new DefaultTopicView(); private List contentTypes = Lists.newArrayList( @@ -320,6 +321,14 @@ public void setOfflineClientsEnabled(boolean offlineClientsEnabled) { this.offlineClientsEnabled = offlineClientsEnabled; } + public String getSchemaRegistryUrl() { + return schemaRegistryUrl; + } + + public void setSchemaRegistryUrl(String schemaRegistryUrl) { + this.schemaRegistryUrl = schemaRegistryUrl; + } + public DefaultTopicView getDefaults() { return defaults; } diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/topic/TopicService.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/topic/TopicService.java index 00cdf58b2d..bc77bc4cf5 100644 --- a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/topic/TopicService.java +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/topic/TopicService.java @@ -24,6 +24,7 @@ import pl.allegro.tech.hermes.api.PatchData; import pl.allegro.tech.hermes.api.Query; import pl.allegro.tech.hermes.api.RawSchema; +import pl.allegro.tech.hermes.api.RawSchemaWithMetadata; import pl.allegro.tech.hermes.api.Topic; import pl.allegro.tech.hermes.api.TopicMetrics; import pl.allegro.tech.hermes.api.TopicName; @@ -243,13 +244,21 @@ public Topic getTopicDetails(TopicName topicName) { @Override public TopicWithSchema getTopicWithSchema(TopicName topicName) { Topic topic = getTopicDetails(topicName); - Optional schema = Optional.empty(); - if (AVRO.equals(topic.getContentType())) { - schema = schemaService.getSchema(topicName.qualifiedName()); + if (!AVRO.equals(topic.getContentType())) { + return topicWithSchema(topic); } + + Optional schema = schemaService.getLatestSchema(topicName.qualifiedName()); + List availableSchemaVersions = schemaService.getVersions(topicName.qualifiedName()); return schema - .map(s -> topicWithSchema(topic, s.value())) - .orElseGet(() -> topicWithSchema(topic)); + .map( + metadata -> + topicWithSchema( + topic, + metadata.getSchemaString(), + metadata.getVersion(), + availableSchemaVersions)) + .orElseGet(() -> topicWithSchema(topic, null, null, availableSchemaVersions)); } @Override diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/topic/schema/SchemaService.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/topic/schema/SchemaService.java index dc851727ac..2a1b3fa497 100644 --- a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/topic/schema/SchemaService.java +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/topic/schema/SchemaService.java @@ -4,6 +4,7 @@ import static pl.allegro.tech.hermes.api.TopicName.fromQualifiedName; import static pl.allegro.tech.hermes.common.logging.LoggingFields.TOPIC_NAME; +import java.util.List; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,9 +35,22 @@ public SchemaService( } public Optional getSchema(String qualifiedTopicName) { - return rawSchemaClient - .getLatestRawSchemaWithMetadata(fromQualifiedName(qualifiedTopicName)) - .map(RawSchemaWithMetadata::getSchema); + return getLatestSchema(qualifiedTopicName).map(RawSchemaWithMetadata::getSchema); + } + + public Optional getLatestSchema(String qualifiedTopicName) { + return rawSchemaClient.getLatestRawSchemaWithMetadata(fromQualifiedName(qualifiedTopicName)); + } + + public List getVersions(String qualifiedTopicName) { + try { + return rawSchemaClient.getVersions(fromQualifiedName(qualifiedTopicName)).stream() + .map(SchemaVersion::value) + .toList(); + } catch (Exception exception) { + logger.error("Could not retrieve schema versions for topic: {}", qualifiedTopicName, exception); + return List.of(); + } } public Optional getSchema(String qualifiedTopicName, SchemaVersion version) { diff --git a/hermes-management/src/main/resources/application-local.yaml b/hermes-management/src/main/resources/application-local.yaml index 470ee0c147..f5b07240eb 100644 --- a/hermes-management/src/main/resources/application-local.yaml +++ b/hermes-management/src/main/resources/application-local.yaml @@ -9,6 +9,9 @@ spring: servlet: path: / +server: + port: 8090 + management: server: servlet: diff --git a/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/config/console/ConsoleConfigurationSpec.groovy b/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/config/console/ConsoleConfigurationSpec.groovy index e2002bc960..60dfc37876 100644 --- a/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/config/console/ConsoleConfigurationSpec.groovy +++ b/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/config/console/ConsoleConfigurationSpec.groovy @@ -1,6 +1,10 @@ package pl.allegro.tech.hermes.management.config.console import com.fasterxml.jackson.databind.ObjectMapper +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.slf4j.LoggerFactory import pl.allegro.tech.hermes.api.ContentType import pl.allegro.tech.hermes.management.config.GroupProperties import pl.allegro.tech.hermes.management.config.TopicProperties @@ -129,4 +133,36 @@ class ConsoleConfigurationSpec extends Specification { and: "repository should be created" repository != null } + + def "should expose configured schema registry URL"() { + given: + def consoleProperties = new ConsoleProperties() + consoleProperties.topic.schemaRegistryUrl = 'https://schema-registry.example.com/' + + when: + def repository = consoleConfiguration.consoleConfigurationRepository( + objectMapper, consoleProperties, new GroupProperties(), new TopicProperties()) + + then: + repository.configuration.contains('"schemaRegistryUrl":"https://schema-registry.example.com/"') + } + + def "should warn once when schema registry URL is absent"() { + given: + def logger = LoggerFactory.getLogger(ConsoleConfiguration) as Logger + def appender = new ListAppender() + appender.start() + logger.addAppender(appender) + + when: + consoleConfiguration.consoleConfigurationRepository( + objectMapper, new ConsoleProperties(), new GroupProperties(), new TopicProperties()) + + then: + appender.list.count { it.formattedMessage.contains('schemaRegistryUrl is not configured') } == 1 + + cleanup: + logger.detachAppender(appender) + appender.stop() + } } diff --git a/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/domain/topic/TopicServiceSpec.groovy b/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/domain/topic/TopicServiceSpec.groovy new file mode 100644 index 0000000000..76d1ee2cb9 --- /dev/null +++ b/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/domain/topic/TopicServiceSpec.groovy @@ -0,0 +1,66 @@ +package pl.allegro.tech.hermes.management.domain.topic + +import pl.allegro.tech.hermes.api.ContentType +import pl.allegro.tech.hermes.api.RawSchemaWithMetadata +import pl.allegro.tech.hermes.domain.topic.TopicRepository +import pl.allegro.tech.hermes.management.domain.topic.schema.SchemaService +import pl.allegro.tech.hermes.test.helper.builder.TopicBuilder +import spock.lang.Specification + +import java.time.Clock + +class TopicServiceSpec extends Specification { + + TopicRepository topicRepository = Stub() + SchemaService schemaService = Mock() + TopicService topicService = new TopicService( + null, topicRepository, null, null, schemaService, null, null, null, Clock.systemUTC(), + null, null, null, null, null) + + def "should enrich an avro topic with active and available schema versions"() { + given: + def topic = TopicBuilder.topic("group.topic").withContentType(ContentType.AVRO).build() + topicRepository.getTopicDetails(topic.name) >> topic + schemaService.getLatestSchema(topic.qualifiedName) >> Optional.of(RawSchemaWithMetadata.of("schema", 101, 3)) + schemaService.getVersions(topic.qualifiedName) >> [1, 2, 3] + + when: + def result = topicService.getTopicWithSchema(topic.name) + + then: + result.schema == "schema" + result.schemaVersion == 3 + result.availableSchemaVersions == [1, 2, 3] + } + + def "should omit schema metadata for a json topic without calling schema service"() { + given: + def topic = TopicBuilder.topic("group.topic").withContentType(ContentType.JSON).build() + topicRepository.getTopicDetails(topic.name) >> topic + + when: + def result = topicService.getTopicWithSchema(topic.name) + + then: + result.schema == null + result.schemaVersion == null + result.availableSchemaVersions == null + 0 * schemaService._ + } + + def "should retain active schema metadata when schema version history is unavailable"() { + given: + def topic = TopicBuilder.topic("group.topic").withContentType(ContentType.AVRO).build() + topicRepository.getTopicDetails(topic.name) >> topic + schemaService.getLatestSchema(topic.qualifiedName) >> Optional.of(RawSchemaWithMetadata.of("schema", 101, 3)) + schemaService.getVersions(topic.qualifiedName) >> [] + + when: + def result = topicService.getTopicWithSchema(topic.name) + + then: + result.schema == "schema" + result.schemaVersion == 3 + result.availableSchemaVersions.empty + } +} diff --git a/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/domain/topic/schema/SchemaServiceSpec.groovy b/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/domain/topic/schema/SchemaServiceSpec.groovy new file mode 100644 index 0000000000..3af1cadc64 --- /dev/null +++ b/hermes-management/src/test/groovy/pl/allegro/tech/hermes/management/domain/topic/schema/SchemaServiceSpec.groovy @@ -0,0 +1,28 @@ +package pl.allegro.tech.hermes.management.domain.topic.schema + +import pl.allegro.tech.hermes.schema.RawSchemaClient +import pl.allegro.tech.hermes.schema.SchemaVersion +import pl.allegro.tech.hermes.management.infrastructure.schema.validator.SchemaValidatorProvider +import spock.lang.Specification + +class SchemaServiceSpec extends Specification { + + RawSchemaClient rawSchemaClient = Mock() + SchemaService schemaService = new SchemaService(rawSchemaClient, Stub(SchemaValidatorProvider), false) + + def "should return integer schema versions"() { + given: + rawSchemaClient.getVersions(_) >> [SchemaVersion.valueOf(1), SchemaVersion.valueOf(3)] + + expect: + schemaService.getVersions("group.topic") == [1, 3] + } + + def "should return empty versions when registry lookup fails"() { + given: + rawSchemaClient.getVersions(_) >> { throw new RuntimeException("unavailable") } + + expect: + schemaService.getVersions("group.topic").empty + } +} diff --git a/integration-tests/src/integrationTest/java/pl/allegro/tech/hermes/integrationtests/management/TopicManagementTest.java b/integration-tests/src/integrationTest/java/pl/allegro/tech/hermes/integrationtests/management/TopicManagementTest.java index 41a9d2592b..aac3ba35b3 100644 --- a/integration-tests/src/integrationTest/java/pl/allegro/tech/hermes/integrationtests/management/TopicManagementTest.java +++ b/integration-tests/src/integrationTest/java/pl/allegro/tech/hermes/integrationtests/management/TopicManagementTest.java @@ -114,6 +114,45 @@ public void shouldCreateTopic() { hermes.api().getTopicResponse(topic.getQualifiedName()).expectStatus().isOk().expectBody(); } + @Test + public void shouldReturnActiveAndAvailableSchemaVersionsForAvroTopic() { + TopicWithSchema topicWithSchema = + topicWithSchema(topicWithRandomName().withContentType(AVRO).build(), SCHEMA); + Topic topic = hermes.initHelper().createTopicWithSchema(topicWithSchema); + hermes.api().saveSchema(topic.getQualifiedName(), AvroUserSchemaLoader.load("/schema/user_v2.avsc").toString()); + + TopicWithSchema response = + hermes + .api() + .getTopicResponse(topic.getQualifiedName()) + .expectStatus() + .isOk() + .expectBody(TopicWithSchema.class) + .returnResult() + .getResponseBody(); + + assertThat(response.getSchemaVersion()).isEqualTo(2); + assertThat(response.getAvailableSchemaVersions()).containsExactly(2, 1); + } + + @Test + public void shouldOmitSchemaVersionMetadataForJsonTopic() { + Topic topic = hermes.initHelper().createTopic(topicWithRandomName().withContentType(JSON).build()); + + TopicWithSchema response = + hermes + .api() + .getTopicResponse(topic.getQualifiedName()) + .expectStatus() + .isOk() + .expectBody(TopicWithSchema.class) + .returnResult() + .getResponseBody(); + + assertThat(response.getSchemaVersion()).isNull(); + assertThat(response.getAvailableSchemaVersions()).isNull(); + } + @Test public void shouldListTopics() { // given From 29dd96b5234c8aacaf89f3774080e186366b2c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20F=C4=85derski?= Date: Thu, 13 Aug 2026 12:25:54 +0200 Subject: [PATCH 02/13] Improved UI for displaying hermes schema version --- .../tech/hermes/api/TopicWithSchema.java | 39 ++++++- .../tech/hermes/api/TopicWithSchemaTest.java | 13 ++- hermes-console/src/api/topic.ts | 1 + hermes-console/src/i18n/en-US/index.ts | 2 +- hermes-console/src/views/topic/TopicView.vue | 1 + .../topic/schema-panel/SchemaPanel.spec.ts | 19 +++- .../views/topic/schema-panel/SchemaPanel.vue | 101 ++++++++++-------- .../management/config/TopicConfiguration.java | 9 +- .../management/domain/topic/TopicService.java | 20 +++- .../src/main/resources/application.yaml | 1 + .../domain/topic/TopicServiceSpec.groovy | 9 +- .../management/TopicManagementTest.java | 2 + 12 files changed, 149 insertions(+), 68 deletions(-) diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicWithSchema.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicWithSchema.java index b04c7bae6d..3baf17ba28 100644 --- a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicWithSchema.java +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/TopicWithSchema.java @@ -20,6 +20,8 @@ public class TopicWithSchema extends Topic { private final List availableSchemaVersions; + private final String schemaSubject; + public TopicWithSchema(Topic topic, String schema) { this( schema, @@ -41,6 +43,7 @@ public TopicWithSchema(Topic topic, String schema) { topic.getCreatedAt(), topic.getModifiedAt(), null, + null, null); } @@ -49,6 +52,15 @@ public TopicWithSchema( String schema, Integer schemaVersion, List availableSchemaVersions) { + this(topic, schema, schemaVersion, availableSchemaVersions, null); + } + + public TopicWithSchema( + Topic topic, + String schema, + Integer schemaVersion, + List availableSchemaVersions, + String schemaSubject) { this( schema, topic.getQualifiedName(), @@ -69,7 +81,8 @@ public TopicWithSchema( topic.getCreatedAt(), topic.getModifiedAt(), schemaVersion, - availableSchemaVersions); + availableSchemaVersions, + schemaSubject); } @JsonCreator @@ -97,7 +110,8 @@ public TopicWithSchema( @JsonProperty("createdAt") Instant createdAt, @JsonProperty("modifiedAt") Instant modifiedAt, @JsonProperty("schemaVersion") Integer schemaVersion, - @JsonProperty("availableSchemaVersions") List availableSchemaVersions) { + @JsonProperty("availableSchemaVersions") List availableSchemaVersions, + @JsonProperty("schemaSubject") String schemaSubject) { super( qualifiedName, description, @@ -120,6 +134,7 @@ public TopicWithSchema( this.schema = schema; this.schemaVersion = schemaVersion; this.availableSchemaVersions = availableSchemaVersions; + this.schemaSubject = schemaSubject; } public static TopicWithSchema topicWithSchema(Topic topic, String schema) { @@ -131,6 +146,15 @@ public static TopicWithSchema topicWithSchema( return new TopicWithSchema(topic, schema, schemaVersion, availableSchemaVersions); } + public static TopicWithSchema topicWithSchema( + Topic topic, + String schema, + Integer schemaVersion, + List availableSchemaVersions, + String schemaSubject) { + return new TopicWithSchema(topic, schema, schemaVersion, availableSchemaVersions, schemaSubject); + } + public static TopicWithSchema topicWithSchema(Topic topic) { return new TopicWithSchema(topic, null); } @@ -170,6 +194,11 @@ public List getAvailableSchemaVersions() { return availableSchemaVersions; } + @JsonInclude(JsonInclude.Include.NON_NULL) + public String getSchemaSubject() { + return schemaSubject; + } + @JsonIgnore public Topic getTopic() { return topic; @@ -190,11 +219,13 @@ public boolean equals(Object o) { return Objects.equals(topic, that.topic) && Objects.equals(schema, that.schema) && Objects.equals(schemaVersion, that.schemaVersion) - && Objects.equals(availableSchemaVersions, that.availableSchemaVersions); + && Objects.equals(availableSchemaVersions, that.availableSchemaVersions) + && Objects.equals(schemaSubject, that.schemaSubject); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), topic, schema, schemaVersion, availableSchemaVersions); + return Objects.hash( + super.hashCode(), topic, schema, schemaVersion, availableSchemaVersions, schemaSubject); } } diff --git a/hermes-api/src/test/java/pl/allegro/tech/hermes/api/TopicWithSchemaTest.java b/hermes-api/src/test/java/pl/allegro/tech/hermes/api/TopicWithSchemaTest.java index e4e13b19af..01cc831a33 100644 --- a/hermes-api/src/test/java/pl/allegro/tech/hermes/api/TopicWithSchemaTest.java +++ b/hermes-api/src/test/java/pl/allegro/tech/hermes/api/TopicWithSchemaTest.java @@ -15,12 +15,15 @@ public class TopicWithSchemaTest { public void shouldSerializeSchemaVersionMetadataWhenPresent() throws Exception { Topic topic = TopicBuilder.topic("group.topic").build(); TopicWithSchema topicWithSchema = - TopicWithSchema.topicWithSchema(topic, "schema", 3, List.of(1, 2, 3)); + TopicWithSchema.topicWithSchema(topic, "schema", 3, List.of(1, 2, 3), "namespace_group.topic-value"); String serialized = objectMapper.writeValueAsString(topicWithSchema); assertThat(serialized) - .contains("\"schemaVersion\":3", "\"availableSchemaVersions\":[1,2,3]"); + .contains( + "\"schemaVersion\":3", + "\"availableSchemaVersions\":[1,2,3]", + "\"schemaSubject\":\"namespace_group.topic-value\""); } @Test @@ -30,9 +33,9 @@ public void shouldOmitAbsentOrEmptySchemaVersionMetadata() throws Exception { String absent = objectMapper.writeValueAsString(TopicWithSchema.topicWithSchema(topic, "schema")); String empty = objectMapper.writeValueAsString( - TopicWithSchema.topicWithSchema(topic, "schema", null, List.of())); + TopicWithSchema.topicWithSchema(topic, "schema", null, List.of(), null)); - assertThat(absent).doesNotContain("schemaVersion", "availableSchemaVersions"); - assertThat(empty).doesNotContain("schemaVersion", "availableSchemaVersions"); + assertThat(absent).doesNotContain("schemaVersion", "availableSchemaVersions", "schemaSubject"); + assertThat(empty).doesNotContain("schemaVersion", "availableSchemaVersions", "schemaSubject"); } } diff --git a/hermes-console/src/api/topic.ts b/hermes-console/src/api/topic.ts index 3e532a978d..5c9128e569 100644 --- a/hermes-console/src/api/topic.ts +++ b/hermes-console/src/api/topic.ts @@ -6,6 +6,7 @@ export interface TopicWithSchema extends Topic { schema: string; schemaVersion?: number; availableSchemaVersions?: number[]; + schemaSubject?: string; } export interface Topic { diff --git a/hermes-console/src/i18n/en-US/index.ts b/hermes-console/src/i18n/en-US/index.ts index c8e34118f5..9bbfc14967 100644 --- a/hermes-console/src/i18n/en-US/index.ts +++ b/hermes-console/src/i18n/en-US/index.ts @@ -503,7 +503,7 @@ const en_US = { title: 'Offline clients', }, schema: { - activeVersion: 'Active version: {version}', + activeVersion: 'Active version:', allVersions: 'All versions ({count})', copy: 'Copy to clipboard', current: 'Current', diff --git a/hermes-console/src/views/topic/TopicView.vue b/hermes-console/src/views/topic/TopicView.vue index 8dda939890..70aaf8d382 100644 --- a/hermes-console/src/views/topic/TopicView.vue +++ b/hermes-console/src/views/topic/TopicView.vue @@ -226,6 +226,7 @@ :topic-name="topic.name" :schema-version="topic.schemaVersion" :available-schema-versions="topic.availableSchemaVersions" + :schema-subject="topic.schemaSubject" :schema-registry-url=" configStore.appConfig?.topic.schemaRegistryUrl " diff --git a/hermes-console/src/views/topic/schema-panel/SchemaPanel.spec.ts b/hermes-console/src/views/topic/schema-panel/SchemaPanel.spec.ts index d164f39f82..4cc822e75f 100644 --- a/hermes-console/src/views/topic/schema-panel/SchemaPanel.spec.ts +++ b/hermes-console/src/views/topic/schema-panel/SchemaPanel.spec.ts @@ -59,21 +59,32 @@ describe('SchemaPanel', () => { availableSchemaVersions: [1, 3, 2], schemaRegistryUrl: 'https://schema-registry.example.com/', topicName: 'group/topic name', + schemaSubject: 'namespace_group/topic name-value', }, }); expect( getByText('topicView.schema.activeVersion', { exact: false }), ).toBeVisible(); + expect(getByText('2', { selector: 'strong' })).toBeVisible(); + expect( + getByRole('button', { + name: 'topicView.schema.allVersions', + }), + ).toHaveClass('v-btn--variant-outlined'); await userEvent.click( getByText('topicView.schema.allVersions', { exact: false }), ); const links = getByRole('list').querySelectorAll('a'); - expect([...links].map((link) => link.textContent)).toEqual(['3', '2', '1']); + expect([...links].map((link) => link.textContent?.trim())).toEqual([ + '3', + '2 topicView.schema.current', + '1', + ]); expect(links[1]).toHaveAttribute( 'href', - 'https://schema-registry.example.com/subjects/group%2Ftopic%20name-value/versions/2', + 'https://schema-registry.example.com/subjects/namespace_group%2Ftopic%20name-value/versions/2', ); expect(links[1]).toHaveAttribute('target', '_blank'); expect(links[1]).toHaveAttribute('rel', 'noopener noreferrer'); @@ -82,7 +93,7 @@ describe('SchemaPanel', () => { it('should show only the active version without registry URL or history', () => { const { getByText, queryByText } = render(SchemaPanel, { - props: { ...props, schemaVersion: 2 }, + props: { ...props, schemaVersion: 2, schemaSubject: 'group.topic-value' }, }); expect( @@ -101,6 +112,7 @@ describe('SchemaPanel', () => { schemaVersion: 2, availableSchemaVersions: [1, 2], schemaRegistryUrl: 'https://schema-registry.example.com', + schemaSubject: 'group.topic-value', }, }); @@ -119,6 +131,7 @@ describe('SchemaPanel', () => { (_, index) => index + 1, ), schemaRegistryUrl: 'https://schema-registry.example.com', + schemaSubject: 'group.topic-value', }, }); diff --git a/hermes-console/src/views/topic/schema-panel/SchemaPanel.vue b/hermes-console/src/views/topic/schema-panel/SchemaPanel.vue index b9facd9fbf..c19d842118 100644 --- a/hermes-console/src/views/topic/schema-panel/SchemaPanel.vue +++ b/hermes-console/src/views/topic/schema-panel/SchemaPanel.vue @@ -11,10 +11,10 @@ topicName: string; schemaVersion?: number; availableSchemaVersions?: number[]; + schemaSubject?: string; schemaRegistryUrl?: string; }>(); const showRawSchema = ref(false); - const showVersionHistory = ref(false); const hasSchemaRegistryUrl = computed( () => !!props.schemaRegistryUrl?.trim(), @@ -28,18 +28,67 @@ () => props.contentType === 'AVRO' && hasSchemaRegistryUrl.value && + !!props.schemaSubject && sortedSchemaVersions.value.length > 0, ); function schemaRegistryVersionUrl(version: number): string { const baseUrl = props.schemaRegistryUrl!.trim().replace(/\/+$/, ''); - return `${baseUrl}/subjects/${encodeURIComponent(props.topicName)}-value/versions/${version}`; + return `${baseUrl}/subjects/${encodeURIComponent(props.schemaSubject!)}/versions/${version}`; }