diff --git a/backend/src/main/java/se/bilhalsning/controller/VehicleController.java b/backend/src/main/java/se/bilhalsning/controller/VehicleController.java new file mode 100644 index 0000000..24b2dbf --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/controller/VehicleController.java @@ -0,0 +1,24 @@ +package se.bilhalsning.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import se.bilhalsning.dto.VehicleInfoResponse; +import se.bilhalsning.service.VehicleLookupService; + +@RestController +@RequestMapping("/api/vehicles") +@RequiredArgsConstructor +public class VehicleController { + + private final VehicleLookupService vehicleLookupService; + + @GetMapping("/{plate}") + public ResponseEntity lookup(@PathVariable String plate) { + VehicleInfoResponse info = vehicleLookupService.lookup(plate); + return ResponseEntity.ok(info); + } +} \ No newline at end of file diff --git a/backend/src/main/java/se/bilhalsning/dto/VehicleInfoResponse.java b/backend/src/main/java/se/bilhalsning/dto/VehicleInfoResponse.java new file mode 100644 index 0000000..5b62bfe --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/VehicleInfoResponse.java @@ -0,0 +1,9 @@ +package se.bilhalsning.dto; + +public record VehicleInfoResponse( + String make, + String model, + int year, + String color, + String fuel +) {} \ No newline at end of file diff --git a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java index bb376e1..366c78a 100644 --- a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java @@ -35,6 +35,21 @@ public class GlobalExceptionHandler { .body(new ErrorResponse(ex.getMessage())); } + @ExceptionHandler(VehicleNotFoundException.class) + public ResponseEntity handleVehicleNotFound(VehicleNotFoundException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse("Inget fordon hittades")); + } + + @ExceptionHandler(VehicleLookupException.class) + public ResponseEntity handleVehicleLookup(VehicleLookupException ex) { + log.error("Vehicle lookup failed", ex); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("Ett internt fel uppstod")); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() diff --git a/backend/src/main/java/se/bilhalsning/exception/VehicleLookupException.java b/backend/src/main/java/se/bilhalsning/exception/VehicleLookupException.java new file mode 100644 index 0000000..7c41628 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/VehicleLookupException.java @@ -0,0 +1,7 @@ +package se.bilhalsning.exception; + +public class VehicleLookupException extends RuntimeException { + public VehicleLookupException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/backend/src/main/java/se/bilhalsning/exception/VehicleNotFoundException.java b/backend/src/main/java/se/bilhalsning/exception/VehicleNotFoundException.java new file mode 100644 index 0000000..0e7c060 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/VehicleNotFoundException.java @@ -0,0 +1,7 @@ +package se.bilhalsning.exception; + +public class VehicleNotFoundException extends RuntimeException { + public VehicleNotFoundException(String plate) { + super("Vehicle not found: " + plate); + } +} \ No newline at end of file diff --git a/backend/src/main/java/se/bilhalsning/service/VehicleLookupService.java b/backend/src/main/java/se/bilhalsning/service/VehicleLookupService.java new file mode 100644 index 0000000..bb654d5 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/VehicleLookupService.java @@ -0,0 +1,141 @@ +package se.bilhalsning.service; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import se.bilhalsning.dto.VehicleInfoResponse; +import se.bilhalsning.exception.VehicleLookupException; +import se.bilhalsning.exception.VehicleNotFoundException; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class VehicleLookupService { + + private static final Logger log = LoggerFactory.getLogger(VehicleLookupService.class); + private static final String BASE_URL = "https://biluppgifter.se/fordon"; + + public VehicleInfoResponse lookup(String plate) { + Document doc = fetchPage(plate); + Map fields = new LinkedHashMap<>(); + + extractSummaryFields(doc, fields); + extractDataSectionFields(doc, fields); + + if (fields.isEmpty() || !fields.containsKey("Fabrikat")) { + throw new VehicleNotFoundException(plate); + } + + String make = fields.getOrDefault("Fabrikat", ""); + String model = buildModel(fields); + int year = parseYearFromFields(fields); + String color = fields.getOrDefault("Färg", ""); + String fuel = fields.getOrDefault("Bränsle", ""); + + return new VehicleInfoResponse(make, model, year, color, fuel); + } + + Document fetchPage(String plate) { + String url = BASE_URL + "/" + plate.toLowerCase() + "/"; + try { + return Jsoup.connect(url) + .userAgent("BilHej/1.0") + .timeout(10_000) + .get(); + } catch (IOException e) { + log.warn("Failed to fetch vehicle data for plate {}", plate, e); + throw new VehicleLookupException("Failed to fetch vehicle data", e); + } + } + + void extractSummaryFields(Document doc, Map fields) { + Elements infoDivs = doc.select(".info > em"); + for (Element em : infoDivs) { + Element span = em.nextElementSibling(); + if (span != null && span.tagName().equals("span")) { + String label = span.text().trim(); + String value = em.ownText().trim(); + if (!label.isEmpty() && !value.isEmpty()) { + fields.putIfAbsent(label, value); + } + } + } + } + + void extractDataSectionFields(Document doc, Map fields) { + Element heading = doc.selectFirst("h2:containsOwn(Fordonsdata)"); + if (heading == null) { + return; + } + + Element section = findParentSection(heading); + if (section == null) { + return; + } + + Elements listItems = section.select("ul.list > li"); + for (Element li : listItems) { + Element labelEl = li.selectFirst("span.label"); + Element valueEl = li.selectFirst("span.value"); + if (labelEl != null && valueEl != null) { + String label = labelEl.text().trim(); + String value = valueEl.text().trim(); + if (!label.isEmpty() && !value.isEmpty() && !value.equals("Logga in")) { + fields.putIfAbsent(label, value); + } + } + } + } + + Map extractFields(Document doc) { + Map fields = new LinkedHashMap<>(); + extractSummaryFields(doc, fields); + extractDataSectionFields(doc, fields); + return fields; + } + + private Element findParentSection(Element heading) { + Element current = heading.parent(); + while (current != null) { + if ("section".equals(current.tagName())) { + return current; + } + current = current.parent(); + } + return heading.parent(); + } + + private String buildModel(Map fields) { + String model = fields.getOrDefault("Modell", ""); + String variant = fields.getOrDefault("Variant", ""); + + if (!model.isEmpty() && !variant.isEmpty()) { + return model + " " + variant; + } + return model.isEmpty() ? variant : model; + } + + private int parseYearFromFields(Map fields) { + String yearText = fields.getOrDefault("Fordonsår / Modellår", ""); + if (yearText.isEmpty()) { + yearText = fields.getOrDefault("Modellår", ""); + } + + if (yearText.isEmpty()) { + return 0; + } + + String[] parts = yearText.split("/"); + try { + return Integer.parseInt(parts[0].trim()); + } catch (NumberFormatException e) { + return 0; + } + } +} \ No newline at end of file