diff --git a/src/main/java/de/avatic/taric/config/ApiKeyFilter.java b/src/main/java/de/avatic/taric/config/ApiKeyFilter.java new file mode 100644 index 0000000..44abd75 --- /dev/null +++ b/src/main/java/de/avatic/taric/config/ApiKeyFilter.java @@ -0,0 +1,82 @@ +package de.avatic.taric.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +@Slf4j +@Component +public class ApiKeyFilter extends OncePerRequestFilter { + + private static final String API_KEY_HEADER = "X-API-Key"; + + private static final List PUBLIC_PATHS = List.of( + "/actuator/health", + "/doc", + "/swagger-ui", + "/v3/api-docs" + ); + + @Value("${taric.api-key}") + private String validApiKey; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String path = request.getRequestURI(); + + + if ("OPTIONS".equals(request.getMethod())) { + filterChain.doFilter(request, response); + return; + } + + if (isPublicPath(path)) { + filterChain.doFilter(request, response); + return; + } + + String apiKey = request.getHeader(API_KEY_HEADER); + + if (apiKey == null) { + apiKey = request.getHeader(API_KEY_HEADER.toLowerCase()); + } + + if (validApiKey != null && validApiKey.equals(apiKey)) { + + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + "api-user", + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } else { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.getWriter().write("{\"message\": \"Invalid or missing API Key\"}"); + } + } + + private boolean isPublicPath(String path) { + return PUBLIC_PATHS.stream().anyMatch(path::startsWith); + } +} \ No newline at end of file diff --git a/src/main/java/de/avatic/taric/config/OpenApiConfig.java b/src/main/java/de/avatic/taric/config/OpenApiConfig.java new file mode 100644 index 0000000..d78ee56 --- /dev/null +++ b/src/main/java/de/avatic/taric/config/OpenApiConfig.java @@ -0,0 +1,45 @@ +package de.avatic.taric.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "TARIC API", + version = "1.0", + description = "REST API for accessing TARIC (Integrated Tariff of the European Union) data. " + + "This API provides endpoints for retrieving tariff information, nomenclature, geographical data, " + + "certificates, conditions, and measures.", + contact = @Contact( + name = "API Support", + email = "support@avatic.de" + ) + ), + servers = { + @Server( + description = "Production Server", + url = "https://taric.avatic.de" + ) + } +) +@SecurityScheme( + name = "apiKey", + type = SecuritySchemeType.APIKEY, + in = SecuritySchemeIn.HEADER, + paramName = "X-API-Key" +) +public class OpenApiConfig { +} \ No newline at end of file diff --git a/src/main/java/de/avatic/taric/config/SecurityConfig.java b/src/main/java/de/avatic/taric/config/SecurityConfig.java index 2cfe74c..a33f70e 100644 --- a/src/main/java/de/avatic/taric/config/SecurityConfig.java +++ b/src/main/java/de/avatic/taric/config/SecurityConfig.java @@ -1,28 +1,54 @@ package de.avatic.taric.config; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; @Configuration @EnableMethodSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final ApiKeyFilter apiKeyFilter; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/actuator/health").permitAll() - .requestMatchers("/actuator/**").hasRole("SERVICE") - .requestMatchers("/api/**").permitAll() - .anyRequest().permitAll() - ); + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/actuator/**").authenticated() + .requestMatchers("/api/**").authenticated() + .requestMatchers("/doc/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() + ).addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class) + .csrf(AbstractHttpConfigurer::disable); return http.build(); - } -} + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of("X-API-Key")); + configuration.setAllowCredentials(false); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/de/avatic/taric/controller/CertificateController.java b/src/main/java/de/avatic/taric/controller/CertificateController.java index 17818a0..5cbfec5 100644 --- a/src/main/java/de/avatic/taric/controller/CertificateController.java +++ b/src/main/java/de/avatic/taric/controller/CertificateController.java @@ -3,6 +3,13 @@ package de.avatic.taric.controller; import de.avatic.taric.model.Certificate; import de.avatic.taric.repository.CertificateRepository; import de.avatic.taric.repository.CertificateTypeRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.data.jdbc.core.mapping.AggregateReference; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -13,6 +20,7 @@ import java.util.List; @RestController @RequestMapping("/api/v1/certificates") +@Tag(name = "Certificates", description = "API endpoints for managing and retrieving certificates") public class CertificateController { private final CertificateRepository certificateRepository; @@ -23,8 +31,21 @@ public class CertificateController { this.certificateTypeRepository = certificateTypeRepository; } + @Operation( + summary = "Get certificates", + description = "Retrieves all certificates or filters by certificate type code. Returns an empty list if the certificate type code is not found." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved certificates", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Certificate.class)) + ) + }) @GetMapping - public Iterable getCertificates(@RequestParam(required = false) String certificateTypeCode) { + public Iterable getCertificates( + @Parameter(description = "Optional certificate type code to filter certificates by type") + @RequestParam(required = false) String certificateTypeCode) { if (certificateTypeCode != null) { return certificateTypeRepository.findByCertificateTypeCode(certificateTypeCode).map(type -> certificateRepository.findByCertificateType(AggregateReference.to(type.getId()))).orElse(List.of()); @@ -32,4 +53,4 @@ public class CertificateController { return certificateRepository.findAll(); } -} +} \ No newline at end of file diff --git a/src/main/java/de/avatic/taric/controller/ConditionController.java b/src/main/java/de/avatic/taric/controller/ConditionController.java index 27b50ea..ad545eb 100644 --- a/src/main/java/de/avatic/taric/controller/ConditionController.java +++ b/src/main/java/de/avatic/taric/controller/ConditionController.java @@ -2,12 +2,19 @@ package de.avatic.taric.controller; import de.avatic.taric.model.ConditionType; import de.avatic.taric.repository.ConditionTypeRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/conditions") +@Tag(name = "Conditions", description = "API endpoints for retrieving condition types") public class ConditionController { private final ConditionTypeRepository conditionTypeRepository; @@ -16,8 +23,19 @@ public class ConditionController { this.conditionTypeRepository = conditionTypeRepository; } + @Operation( + summary = "Get all condition types", + description = "Retrieves a complete list of all available condition types in the system" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved condition types", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ConditionType.class)) + ) + }) @GetMapping public Iterable getConditions() { return conditionTypeRepository.findAll(); } -} +} \ No newline at end of file diff --git a/src/main/java/de/avatic/taric/controller/GeoController.java b/src/main/java/de/avatic/taric/controller/GeoController.java index f5b11e8..9eb750e 100644 --- a/src/main/java/de/avatic/taric/controller/GeoController.java +++ b/src/main/java/de/avatic/taric/controller/GeoController.java @@ -3,6 +3,13 @@ package de.avatic.taric.controller; import de.avatic.taric.model.Geo; import de.avatic.taric.model.GeoGroup; import de.avatic.taric.service.GeoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -14,22 +21,55 @@ import java.util.Optional; @RestController @RequestMapping("/api/v1/geo") +@Tag(name = "Geography", description = "API endpoints for geographical data including countries and country groups") public class GeoController { - private final GeoService geoService; public GeoController(GeoService geoService) { this.geoService = geoService; } + @Operation( + summary = "Get geographical information by country code", + description = "Retrieves detailed geographical information for a specific country using its country code" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved geographical information", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Geo.class)) + ), + @ApiResponse( + responseCode = "404", + description = "Country not found", + content = @Content + ) + }) @GetMapping("") - public Optional getGeo(@RequestParam String countryCode) { + public Optional getGeo( + @Parameter(description = "ISO country code (e.g., DE, US, FR)", required = true) + @RequestParam String countryCode) { return geoService.getGeo(countryCode); } + @Operation( + summary = "Get geographical groups", + description = "Retrieves geographical groups either by country code or by group abbreviation. Only one parameter should be provided at a time." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved geographical groups", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = GeoGroup.class)) + ) + }) @GetMapping("/group") - public List getGeoGroup(@RequestParam(required = false) String countryCode, @RequestParam(required = false) String abbr) { + public List getGeoGroup( + @Parameter(description = "ISO country code to find all groups containing this country") + @RequestParam(required = false) String countryCode, + @Parameter(description = "Group abbreviation to retrieve a specific geographical group") + @RequestParam(required = false) String abbr) { if (countryCode != null && abbr == null) return geoService.getGeoGroupByCountryCode(countryCode); @@ -39,4 +79,4 @@ public class GeoController { return Collections.emptyList(); } -} +} \ No newline at end of file diff --git a/src/main/java/de/avatic/taric/controller/MeasureController.java b/src/main/java/de/avatic/taric/controller/MeasureController.java index 1a5f1c9..5a32cbd 100644 --- a/src/main/java/de/avatic/taric/controller/MeasureController.java +++ b/src/main/java/de/avatic/taric/controller/MeasureController.java @@ -6,6 +6,12 @@ import de.avatic.taric.model.MeasureSeries; import de.avatic.taric.repository.MeasureActionRepository; import de.avatic.taric.repository.MeasureRepository; import de.avatic.taric.repository.MeasureSeriesRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -14,6 +20,7 @@ import java.util.List; @RestController @RequestMapping("/api/v1/measures") +@Tag(name = "Measures", description = "API endpoints for trade measures, measure series, and measure actions") public class MeasureController { private final MeasureRepository measureRepository; @@ -26,18 +33,51 @@ public class MeasureController { this.measureActionRepository = measureActionRepository; } + @Operation( + summary = "Get all measures", + description = "Retrieves a complete list of all trade measures in the system" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved measures", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Measure.class)) + ) + }) @GetMapping public Iterable getMeasures() { return measureRepository.findAll(); } + @Operation( + summary = "Get all measure series", + description = "Retrieves a complete list of all measure series that group related measures together" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved measure series", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = MeasureSeries.class)) + ) + }) @GetMapping("/series") public Iterable getMeasuresSeries() { return measureSeriesRepository.findAll(); } + @Operation( + summary = "Get all measure actions", + description = "Retrieves a complete list of all measure actions that define possible actions on measures" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved measure actions", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = MeasureAction.class)) + ) + }) @GetMapping("/action") public Iterable getMeasuresAction() { return measureActionRepository.findAll(); } -} +} \ No newline at end of file diff --git a/src/main/java/de/avatic/taric/controller/NomenclatureController.java b/src/main/java/de/avatic/taric/controller/NomenclatureController.java index 9b19dd1..0573522 100644 --- a/src/main/java/de/avatic/taric/controller/NomenclatureController.java +++ b/src/main/java/de/avatic/taric/controller/NomenclatureController.java @@ -2,6 +2,13 @@ package de.avatic.taric.controller; import de.avatic.taric.model.Nomenclature; import de.avatic.taric.service.NomenclatureService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -12,6 +19,7 @@ import java.util.Optional; @RestController @RequestMapping("/api/v1/nomenclature") +@Tag(name = "Nomenclature", description = "API endpoints for HS code nomenclature and tariff classification hierarchy") public class NomenclatureController { private final NomenclatureService nomenclatureService; @@ -20,20 +28,63 @@ public class NomenclatureController { this.nomenclatureService = nomenclatureService; } + @Operation( + summary = "Get nomenclature by HS code", + description = "Retrieves a specific nomenclature entry by its Harmonized System (HS) code" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved nomenclature", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Nomenclature.class)) + ), + @ApiResponse( + responseCode = "404", + description = "HS code not found", + content = @Content + ) + }) @GetMapping("") - public Optional getNomenclature(@RequestParam String hscode) { + public Optional getNomenclature( + @Parameter(description = "Harmonized System code (e.g., 0101, 010121)", required = true) + @RequestParam String hscode) { return nomenclatureService.getNomenclature(hscode); } + @Operation( + summary = "Get declarable child nomenclatures", + description = "Retrieves all declarable (leaf-level) child nomenclatures under a given HS code. These are codes that can be used for customs declarations." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved declarable nomenclatures", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Nomenclature.class)) + ) + }) @GetMapping("/declarable") - public List getDeclarable(@RequestParam String hscode) { + public List getDeclarable( + @Parameter(description = "Parent HS code to retrieve declarable children from", required = true) + @RequestParam String hscode) { return nomenclatureService.getDeclarableChildren(hscode); } + @Operation( + summary = "Get nomenclature cascade", + description = "Retrieves the complete hierarchical cascade of a nomenclature, including all parent and child relationships in the tariff classification tree" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved nomenclature cascade", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Nomenclature.class)) + ) + }) @GetMapping("/cascade") - public List getCascade(@RequestParam String hscode) { + public List getCascade( + @Parameter(description = "HS code to retrieve the complete hierarchy for", required = true) + @RequestParam String hscode) { var found = nomenclatureService.getNomenclatureCascade(hscode); return found; } - -} +} \ No newline at end of file diff --git a/src/main/java/de/avatic/taric/controller/TariffController.java b/src/main/java/de/avatic/taric/controller/TariffController.java index cfcb8f5..1e0d511 100644 --- a/src/main/java/de/avatic/taric/controller/TariffController.java +++ b/src/main/java/de/avatic/taric/controller/TariffController.java @@ -2,6 +2,14 @@ package de.avatic.taric.controller; import de.avatic.taric.model.AppliedMeasure; import de.avatic.taric.service.TariffService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -12,18 +20,44 @@ import java.util.Map; @RestController @RequestMapping("/api/v1/tariff") +@Tag(name = "Tariff", description = "API endpoints for calculating and retrieving applied tariff rates and measures") public class TariffController { - private final TariffService tariffService; public TariffController(TariffService tariffService) { this.tariffService = tariffService; } + @Operation( + summary = "Get applied tariff measures", + description = "Retrieves all applicable tariff measures for a specific HS code and country combination. " + + "Returns a nested map structure grouping measures by measure type and additional code." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved applied measures", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = Map.class), + examples = @ExampleObject( + value = "{ \"103\": { \"default\": [ {...} ] }, \"142\": { \"C999\": [ {...} ] } }" + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "HS code or country not found", + content = @Content + ) + }) @GetMapping("") - public Map>> getTariffRate(@RequestParam String hsCode, @RequestParam String countryCode) { + public Map>> getTariffRate( + @Parameter(description = "Harmonized System code (e.g., 0101210000)", required = true, example = "0101210000") + @RequestParam String hsCode, + @Parameter(description = "ISO country code (e.g., US, CN, GB)", required = true, example = "US") + @RequestParam String countryCode) { return tariffService.getAppliedMeasures(hsCode, countryCode); } - -} +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 75e85ce..96441e5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,3 @@ spring.application.name=taric -logging.level.org.springframework.data.jdbc.core.convert.RowDocumentResultSetExtractor=ERROR \ No newline at end of file +logging.level.org.springframework.data.jdbc.core.convert.RowDocumentResultSetExtractor=ERROR +springdoc.swagger-ui.path=/doc