From 8fd9e350e566006d4d95a588bf6672ad5387ccca Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Thu, 9 Apr 2026 06:29:42 +0200 Subject: [PATCH] V1.1 Legacy-API-Key-Fallback und Base-URL-Validierung korrigiert --- config/application-local.example.properties | 5 +- .../MultiProviderConfigurationParser.java | 38 ++++- .../MultiProviderConfigurationValidator.java | 36 ++++- .../MultiProviderConfigurationTest.java | 138 ++++++++++++++++++ 4 files changed, 210 insertions(+), 7 deletions(-) diff --git a/config/application-local.example.properties b/config/application-local.example.properties index 93f817d..3389d12 100644 --- a/config/application-local.example.properties +++ b/config/application-local.example.properties @@ -67,7 +67,10 @@ ai.provider.openai-compatible.model=gpt-4o-mini # HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein). ai.provider.openai-compatible.timeoutSeconds=30 -# API-Schluessel. Die Umgebungsvariable OPENAI_COMPATIBLE_API_KEY hat Vorrang. +# API-Schluessel. +# Vorrangreihenfolge: OPENAI_COMPATIBLE_API_KEY (Umgebungsvariable) > +# PDF_UMBENENNER_API_KEY (veraltete Umgebungsvariable, weiterhin akzeptiert) > +# ai.provider.openai-compatible.apiKey (dieser Wert) ai.provider.openai-compatible.apiKey=your-openai-api-key-here # --------------------------------------------------------------------------- diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java index 2ca104d..9854519 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java @@ -64,6 +64,15 @@ public class MultiProviderConfigurationParser { /** Environment variable for the OpenAI-compatible provider API key. */ static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_API_KEY"; + /** + * Legacy environment variable for the OpenAI-compatible provider API key. + *

+ * Accepted as a fallback when {@code OPENAI_COMPATIBLE_API_KEY} is not set. + * Existing installations that set this variable continue to work without change. + * New installations should prefer {@code OPENAI_COMPATIBLE_API_KEY}. + */ + static final String ENV_LEGACY_OPENAI_API_KEY = "PDF_UMBENENNER_API_KEY"; + /** Environment variable for the Anthropic Claude provider API key. */ static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_API_KEY"; @@ -131,7 +140,7 @@ public class MultiProviderConfigurationParser { String model = getOptionalString(props, PROP_OPENAI_MODEL); int timeout = parseTimeoutSeconds(props, PROP_OPENAI_TIMEOUT); String baseUrl = getOptionalString(props, PROP_OPENAI_BASE_URL); - String apiKey = resolveApiKey(props, PROP_OPENAI_API_KEY, ENV_OPENAI_API_KEY); + String apiKey = resolveOpenAiApiKey(props); return new ProviderConfiguration(model, timeout, baseUrl, apiKey); } @@ -179,6 +188,33 @@ public class MultiProviderConfigurationParser { } } + /** + * Resolves the effective API key for the OpenAI-compatible provider. + *

+ * Resolution order: + *

    + *
  1. {@code OPENAI_COMPATIBLE_API_KEY} environment variable
  2. + *
  3. {@code PDF_UMBENENNER_API_KEY} environment variable (legacy fallback; + * accepted for backward compatibility with existing installations)
  4. + *
  5. {@code ai.provider.openai-compatible.apiKey} property
  6. + *
+ * + * @param props the configuration properties + * @return the resolved API key; never {@code null}, but may be blank + */ + private String resolveOpenAiApiKey(Properties props) { + String primary = environmentLookup.apply(ENV_OPENAI_API_KEY); + if (primary != null && !primary.isBlank()) { + return primary.trim(); + } + String legacy = environmentLookup.apply(ENV_LEGACY_OPENAI_API_KEY); + if (legacy != null && !legacy.isBlank()) { + return legacy.trim(); + } + String propsValue = props.getProperty(PROP_OPENAI_API_KEY); + return (propsValue != null) ? propsValue.trim() : ""; + } + /** * Resolves the effective API key for a provider family. *

diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java index 6e499a4..7a30d97 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java @@ -5,6 +5,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import java.net.URI; import java.util.ArrayList; import java.util.List; @@ -16,8 +17,9 @@ import java.util.List; *

  • {@code ai.provider.active} refers to a recognised provider family.
  • *
  • {@code model} is non-blank.
  • *
  • {@code timeoutSeconds} is a positive integer.
  • - *
  • {@code baseUrl} is non-blank (required for the OpenAI-compatible family; - * the Claude family always has a default).
  • + *
  • {@code baseUrl} is a syntactically valid absolute URI with scheme {@code http} or + * {@code https} (required for the OpenAI-compatible family; the Claude family always + * has a default, but it is validated with the same rules).
  • *
  • {@code apiKey} is non-blank after environment-variable precedence has been applied * by {@link MultiProviderConfigurationParser}.
  • * @@ -83,16 +85,40 @@ public class MultiProviderConfigurationValidator { } /** - * Validates base URL presence. + * Validates the base URL of the active provider. *

    + * The URL must be: + *

    * The OpenAI-compatible family requires an explicit base URL. * The Claude family always has a default ({@code https://api.anthropic.com}) applied by the - * parser, so this check is a safety net rather than a primary enforcement mechanism. + * parser, so this check serves both as a primary and safety-net enforcement. */ private void validateBaseUrl(AiProviderFamily family, ProviderConfiguration config, String providerLabel, List errors) { - if (config.baseUrl() == null || config.baseUrl().isBlank()) { + String baseUrl = config.baseUrl(); + if (baseUrl == null || baseUrl.isBlank()) { errors.add("- " + providerLabel + ".baseUrl: must not be blank"); + return; + } + try { + URI uri = URI.create(baseUrl); + if (!uri.isAbsolute()) { + errors.add("- " + providerLabel + ".baseUrl: must be an absolute URI with http or https scheme, got: '" + + baseUrl + "'"); + return; + } + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + errors.add("- " + providerLabel + ".baseUrl: scheme must be http or https, got: '" + + scheme + "' in '" + baseUrl + "'"); + } + } catch (IllegalArgumentException e) { + errors.add("- " + providerLabel + ".baseUrl: not a valid URI: '" + baseUrl + "' (" + e.getMessage() + ")"); } } diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java index 4d7a5e3..650ffce 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java @@ -245,6 +245,144 @@ class MultiProviderConfigurationTest { "Env var must override properties API key for Claude"); } + // ========================================================================= + // Test: legacy env var PDF_UMBENENNER_API_KEY + // ========================================================================= + + /** + * {@code PDF_UMBENENNER_API_KEY} is set, {@code OPENAI_COMPATIBLE_API_KEY} is absent. + * The legacy variable must be accepted as a fallback for the OpenAI-compatible provider. + */ + @Test + void legacyEnvVarPdfUmbenennerApiKeyUsedWhenPrimaryAbsent() { + Properties props = fullOpenAiProperties(); + props.remove("ai.provider.openai-compatible.apiKey"); + + Function envWithLegacy = key -> + MultiProviderConfigurationParser.ENV_LEGACY_OPENAI_API_KEY.equals(key) + ? "legacy-env-key" : null; + + MultiProviderConfiguration config = parseAndValidate(props, envWithLegacy); + assertEquals("legacy-env-key", config.openAiCompatibleConfig().apiKey(), + "Legacy env var PDF_UMBENENNER_API_KEY must be used when OPENAI_COMPATIBLE_API_KEY is absent"); + } + + /** + * {@code OPENAI_COMPATIBLE_API_KEY} takes precedence over {@code PDF_UMBENENNER_API_KEY}. + */ + @Test + void primaryEnvVarTakesPrecedenceOverLegacyEnvVar() { + Properties props = fullOpenAiProperties(); + props.remove("ai.provider.openai-compatible.apiKey"); + + Function envBoth = key -> { + if (MultiProviderConfigurationParser.ENV_OPENAI_API_KEY.equals(key)) return "primary-key"; + if (MultiProviderConfigurationParser.ENV_LEGACY_OPENAI_API_KEY.equals(key)) return "legacy-key"; + return null; + }; + + MultiProviderConfiguration config = parseAndValidate(props, envBoth); + assertEquals("primary-key", config.openAiCompatibleConfig().apiKey(), + "OPENAI_COMPATIBLE_API_KEY must take precedence over PDF_UMBENENNER_API_KEY"); + } + + /** + * Neither env var is set; the properties value is used as final fallback. + */ + @Test + void propertiesApiKeyUsedWhenNoEnvVarSet() { + Properties props = fullOpenAiProperties(); + props.setProperty("ai.provider.openai-compatible.apiKey", "props-only-key"); + + MultiProviderConfiguration config = parseAndValidate(props, NO_ENV); + assertEquals("props-only-key", config.openAiCompatibleConfig().apiKey(), + "Properties API key must be used when no env var is set"); + } + + // ========================================================================= + // Tests: base URL validation + // ========================================================================= + + /** + * OpenAI-compatible provider with an invalid (non-URI) base URL must be rejected. + */ + @Test + void rejectsInvalidBaseUrlForActiveOpenAiProvider() { + Properties props = fullOpenAiProperties(); + props.setProperty("ai.provider.openai-compatible.baseUrl", "not a valid url at all ://"); + + MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV); + MultiProviderConfiguration config = parser.parse(props); + + InvalidStartConfigurationException ex = assertThrows( + InvalidStartConfigurationException.class, + () -> new MultiProviderConfigurationValidator().validate(config)); + + assertTrue(ex.getMessage().contains("baseUrl"), + "Error message must reference baseUrl"); + } + + /** + * Claude provider with an invalid base URL must be rejected when Claude is active. + */ + @Test + void rejectsInvalidBaseUrlForActiveClaudeProvider() { + Properties props = fullClaudeProperties(); + props.setProperty("ai.provider.claude.baseUrl", "ftp://api.anthropic.com"); + + MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV); + MultiProviderConfiguration config = parser.parse(props); + + InvalidStartConfigurationException ex = assertThrows( + InvalidStartConfigurationException.class, + () -> new MultiProviderConfigurationValidator().validate(config)); + + assertTrue(ex.getMessage().contains("baseUrl"), + "Error message must reference baseUrl"); + assertTrue(ex.getMessage().contains("ftp"), + "Error message must mention the invalid scheme"); + } + + /** + * A relative URI (no scheme, no host) must be rejected. + */ + @Test + void rejectsRelativeUriAsBaseUrl() { + Properties props = fullOpenAiProperties(); + props.setProperty("ai.provider.openai-compatible.baseUrl", "/v1/chat"); + + MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV); + MultiProviderConfiguration config = parser.parse(props); + + InvalidStartConfigurationException ex = assertThrows( + InvalidStartConfigurationException.class, + () -> new MultiProviderConfigurationValidator().validate(config)); + + assertTrue(ex.getMessage().contains("baseUrl"), + "Error message must reference baseUrl"); + } + + /** + * A non-http/https scheme (e.g. {@code ftp://}) must be rejected. + */ + @Test + void rejectsNonHttpSchemeAsBaseUrl() { + Properties props = fullOpenAiProperties(); + props.setProperty("ai.provider.openai-compatible.baseUrl", "ftp://api.example.com"); + + MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV); + MultiProviderConfiguration config = parser.parse(props); + + InvalidStartConfigurationException ex = assertThrows( + InvalidStartConfigurationException.class, + () -> new MultiProviderConfigurationValidator().validate(config)); + + assertTrue(ex.getMessage().contains("baseUrl"), + "Error message must reference baseUrl"); + assertTrue(ex.getMessage().contains("ftp"), + "Error message must mention the invalid scheme"); + } + // ========================================================================= // Mandatory test case 9 // =========================================================================