diff --git a/ANSWERS.md b/ANSWERS.md index 9cd3f0d..86c736e 100644 --- a/ANSWERS.md +++ b/ANSWERS.md @@ -2,10 +2,141 @@ ## A - The entities +Below a short explanation about the decision taken regarding domain model design. + +![Domain Model](DomainModel.png) + +### Brand + +The service will support model specifications from several brands/factories and each of these brands will have a +unique list of car specifications that they sell. + +Each `Brand` will contain a `name` and their list of `Specification`s. + +### Specification and Modification +Based on the car models structure and data provided into the sample file, I've identified a hierarchy of entities based +on commons attributes as `name`, `Engine` and `Wheel`. + +An `Specification`s represent the base setup for a set of `Modifications`s that define small variants from the +original spec. + +> *NOTE : Some decisions were taken with the goal of design a normalized Domain Model and use some advance technics as +> using hierarchies and JPA, but if the domain model is so simple as the sample data, maybe a unique `Specification` +>entity containing all the data is much simpler for maintenance.* + +### Engine + +Based on the provided sample data the same `Engine` information could be used on several `Specifications`, so I've +decided to create just one engine per _power:type_ tuple. This means that many `Specification` instances could be pointed to +the same `Engine` if `power` and `type` attributes are equals. + +Also, I've modeled `EngineType` as an _Enum_. The pros are that this simplifies the string typos and keeps integrity in +the domain model. The cons are that every time that a new engine type appears, a new version of the service must be +deployed. An improvement is to transform this _Enum_ in a _Value Object_ list stored into the DataBase. + +### Wheel + +I've taken similar decisions for `Wheel` entities. They are unique id _size:type_ tuple is equaled. +In this case, I keep the type as a `String` + +### Ingestion +This entity is used to maintain a history of each `Brand`'s ingestion, registering the source, the ingestion date, +and the `Specifications` processed and added during the process. +This also allows checking the duplicated ingestion of the same source. + + ## B - Ingest the data +The ingestion process is designed and implemented to accomplish with the next definitions: + +* Factories will only send catalogs for a single Brand +* Factories will send monthly files that should be ingested +* The same file should not be ingested twice but two different files from the same factory could contain the same + car model +* If the model was not previously ingested, this will be created based on the data source +* If the model was previously ingested, will be replaced with the new model specification +* The same strategy will be applied to all the different brands and the data source provided + +![Class Diagram](ClassDiagram.png) + +### Entities and Repositories - JPA + +All the _Domain Model_'s entities were annotated using JPA and the corresponding _Repository_ was implemented to keep +this complete model persistent. + +### Brand Builder +_Factory Method_ that creates `Specification` for each car model providedfor a single `Brand`, parsing and adapting +specific data sources (e.g. XML files, JSON files, web services, etc). + +A `FordBrandBuilder` was implemented to parse XML files provided from Ford brand and create the +corresponding cars `Specification`. + +A new `BrandBuilder` instance could be added into the `BuildersConfigurations` and the generated `Brand` will be +ingested using the same `IngestStrategy` for all of them. + +### Ingest Strategy +`IngestStrategy` implement the strategy to ingest new or existant `Brand`s and `Specificatio`s. + +A `MergeBrandsIngestStrategy` was implemented to follow this definition: +* If the model's `Specification` was not previously ingested, this will be created based on the data source +* If the model's `Specification` was previously ingested, will be replaced with the new model specification ## C - Expose data with a RESTful API +For this project, I've enabled _Spring Data Web_ allowing to publish Repositories automatically with **HATEOS** support. + +I will take advantage of this to expose the repositories of `Brand`s and `Specification`s +which allow accomplishing the next two requirements: + +### Basic Endpoints +* Get a car specification by id +```curl GET http://localhost:8080/specifications/{id}``` + +* Get all the car specifications by brand +```curl GET http://localhost:8080/specifications/search/findByBrandName?brand=Ford``` + +### Spring Data Web Endpoints + +_Spring Data Web_ also will enable some endpoints that allow accessing `Brand`s and `Specification`s as for example: + +* Find `Specification`'s by name - `curl GET http://localhost:8080/specifications/search/findByName?name=Ford%20Fiesta` +* Get all `Specification`'s by `Brand`'s id - `http://localhost:8080/brands/1/specifications` + ## D - Adding images -## E - Improvements \ No newline at end of file +### Domain Model +In order to support attach images to each car's `Specification` I've added to the domain model the `Image` entity +which contains the `data` (bytes) of the images and information extra information as `fileName` and `fileType`. + +Each `Specification` has a unique `Image` associated. + +### Service and Repository + +A `ImageService` was implemented in order to manage storage access to the image entity associated to each +`Specification`, and an `AbstractSpecRepository` was implemented to allows associating images to both car's +`Specification`s and `Modification`s. + +### Image's endpoints + +The RESTFull API of the `Specification` entity was modified adding access to the `Image`s. This two endpoint are: + +* `curl -X POST http://localhost:8080/specifications/{SpecificationId}/image -H 'content-type: multipart/form-data' +-F file=@{local-path}` + +* `curl -X GET http://localhost:8080/specifications/{SpecificationId}/image` + +## E - Improvements + +Here a list of TODOs for improving the solution in order to deploy it in a production environment: + +Functional improvements +* Improve `IngestionStrategy` in order to not duplicate `Engine`s and `Wheel`s +* Add some sample `BrandBuilder` to generate `Brand`s from different data sources, as e.g. RestAPIs. +* Including _Integration Test_ for the main endpoints + +Non-functional improvements +* Add _Spring Boot Actuator_ to add production-ready features **[Done]** + - Implement custom _HealthIndicator_ for monitor `Ingestion`s **[Todo]** +* Add Spring profiles for _development (dev)_ and _Production (prod)_ environment **[Done]** +* Add _Spring Security_ to add authentication and authorization to the service **[Todo]** +* Add _Swagger_ documentation **[Todo]** +* _Docker_ support **[Todo]** diff --git a/ClassDiagram.png b/ClassDiagram.png new file mode 100644 index 0000000..89a846d Binary files /dev/null and b/ClassDiagram.png differ diff --git a/DomainModel.png b/DomainModel.png new file mode 100644 index 0000000..c365265 Binary files /dev/null and b/DomainModel.png differ diff --git a/cars/.gitignore b/cars/.gitignore index 153c933..cf95f43 100644 --- a/cars/.gitignore +++ b/cars/.gitignore @@ -27,3 +27,4 @@ HELP.md ### VS Code ### .vscode/ +/logs/ diff --git a/cars/pom.xml b/cars/pom.xml index 43b1e4c..05808f9 100644 --- a/cars/pom.xml +++ b/cars/pom.xml @@ -43,6 +43,17 @@ lombok true + + + org.springframework.boot + spring-boot-starter-data-rest + + + + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot diff --git a/cars/src/main/java/com/mooveit/cars/CarsApplication.java b/cars/src/main/java/com/mooveit/cars/CarsApplication.java index 9599fc9..cd3f468 100644 --- a/cars/src/main/java/com/mooveit/cars/CarsApplication.java +++ b/cars/src/main/java/com/mooveit/cars/CarsApplication.java @@ -3,11 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.data.web.config.EnableSpringDataWebSupport; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableConfigurationProperties @EnableScheduling +@EnableSpringDataWebSupport public class CarsApplication { public static void main(String[] args) { diff --git a/cars/src/main/java/com/mooveit/cars/controllers/FileController.java b/cars/src/main/java/com/mooveit/cars/controllers/FileController.java new file mode 100644 index 0000000..4efcb23 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/controllers/FileController.java @@ -0,0 +1,56 @@ + +package com.mooveit.cars.controllers; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import com.mooveit.cars.domain.Image; +import com.mooveit.cars.services.ImageService; + +@RestController +public class FileController { + + private static final Logger log = LoggerFactory.getLogger(FileController.class); + + @Autowired + private ImageService imageService; + + @PostMapping("/specifications/{id}/image") + public Image uploadFile(@PathVariable(name = "id") Long specId, @RequestParam("file") MultipartFile file) { + + log.info(String.format("Setting image for Specification id %d", specId)); + + Image image = imageService.storeFile(specId, file); + + String fileDownloadUri = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(String.format("/specifications/%d/image", specId)).toUriString(); + image.setUrl(fileDownloadUri); + + return image; + } + + @GetMapping("/specifications/{id}/image") + public ResponseEntity downloadFile(@PathVariable(name = "id") Long specId) { + + log.info(String.format("Retrieving image for Specification id %d", specId)); + + Image image = imageService.getFile(specId); + + return ResponseEntity.ok().contentType(MediaType.parseMediaType(image.getFileType())) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + image.getFileName() + "\"") + .body(new ByteArrayResource(image.getData())); + } + +} \ No newline at end of file diff --git a/cars/src/main/java/com/mooveit/cars/domain/AbstractSpec.java b/cars/src/main/java/com/mooveit/cars/domain/AbstractSpec.java new file mode 100644 index 0000000..2a13a2b --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/AbstractSpec.java @@ -0,0 +1,116 @@ +package com.mooveit.cars.domain; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.DiscriminatorColumn; +import javax.persistence.Entity; +import javax.persistence.Inheritance; +import javax.persistence.InheritanceType; +import javax.persistence.ManyToOne; +import javax.persistence.OneToOne; +import javax.persistence.Table; +import javax.validation.constraints.NotNull; + +/** + * Base specification info for both car's {@link Specification} and {@link Modification}. + *
+ * The complete hierarchy is mapped into a single table to make data access more + * efficient, avoiding the inner join between tables. + */ +@Entity +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name="spec_type") +@Table(name="specifications") +public abstract class AbstractSpec extends BaseEntity { + + /** + * Car's model name + */ + @NotNull + @Column(length = 125) + private String name; + + /** + * Year of putting into production + */ + @Column(name = "yearFrom") + private Integer from; + + /** + * Year of stopping production + */ + @Column(name = "yearTo") + private Integer to; + + @ManyToOne(cascade = CascadeType.ALL) + private Engine engine; + + @ManyToOne(cascade = CascadeType.ALL) + private Wheel wheel; + + @OneToOne(cascade = CascadeType.ALL) + private Image image; + + protected AbstractSpec() { + } + + public AbstractSpec(String name, Integer from, Integer to, Engine engine, Wheel wheel) { + this.name = name; + this.from = from; + this.to = to; + this.engine = engine; + this.wheel = wheel; + } + + + public String getName() { + return name; + } + + public Integer getFrom() { + return from; + } + + public Integer getTo() { + return to; + } + + public Engine getEngine() { + return engine; + } + + public Wheel getWheel() { + return wheel; + } + + public void setName(String name) { + this.name = name; + } + + public void setFrom(Integer from) { + this.from = from; + } + + public void setTo(Integer to) { + this.to = to; + } + + public void setEngine(Engine engine) { + this.engine = engine; + } + + public void setWheel(Wheel wheel) { + this.wheel = wheel; + } + + public Image getImage() { + return image; + } + + public void setImage(Image image) { + this.image = image; + } + + + +} diff --git a/cars/src/main/java/com/mooveit/cars/domain/BaseEntity.java b/cars/src/main/java/com/mooveit/cars/domain/BaseEntity.java new file mode 100644 index 0000000..8d077ec --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/BaseEntity.java @@ -0,0 +1,28 @@ +package com.mooveit.cars.domain; + +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.MappedSuperclass; + +/** + * Base entity with all common state and behavior of the entity models. + */ + +@MappedSuperclass +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + +} diff --git a/cars/src/main/java/com/mooveit/cars/domain/Brand.java b/cars/src/main/java/com/mooveit/cars/domain/Brand.java new file mode 100644 index 0000000..5d2c7c1 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/Brand.java @@ -0,0 +1,57 @@ +package com.mooveit.cars.domain; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.OneToMany; +import javax.persistence.Table; +import javax.validation.constraints.NotNull; + +/** + * Car's brand model. Root entity that model the complete and updated catalog + * for a specific car Brand. + */ +@Entity +@Table(name = "brand") +public class Brand extends BaseEntity { + + @NotNull + private String name; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "brand") + private List specifications; + + protected Brand() { + super(); + }; + + public Brand(String name) { + super(); + this.name = name; + this.specifications = new ArrayList(); + } + + public void addSpecification(Specification spec) { + this.specifications.add(spec); + } + + public Stream getSpecifications() { + return specifications.stream(); + } + + protected void setSpecifications(List specifications) { + this.specifications = specifications; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/domain/Engine.java b/cars/src/main/java/com/mooveit/cars/domain/Engine.java new file mode 100644 index 0000000..bf1b2a8 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/Engine.java @@ -0,0 +1,59 @@ +package com.mooveit.cars.domain; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Table; +import javax.validation.constraints.NotNull; + +/** + * The entity to model the car's engines for each {@link AbstractSpec}. + *
+ * Engines with the same type and power will be unique. +*/ +@Entity +@Table(name = "engine") +public class Engine extends BaseEntity { + + /** + * Engine's power + */ + @NotNull + private Integer power; + + /** + * Engine's type + */ + @Enumerated(EnumType.STRING) + @Column(length = 8) + @NotNull + private EngineType type; + + protected Engine() { + super(); + } + + public Engine(Integer power, EngineType type) { + super(); + this.power = power; + this.type = type; + } + + public Integer getPower() { + return power; + } + + public EngineType getType() { + return type; + } + + public void setPower(Integer power) { + this.power = power; + } + + public void setType(EngineType type) { + this.type = type; + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/domain/EngineType.java b/cars/src/main/java/com/mooveit/cars/domain/EngineType.java new file mode 100644 index 0000000..0148e8c --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/EngineType.java @@ -0,0 +1,7 @@ +package com.mooveit.cars.domain; + +public enum EngineType { + HYBRID, + GAS, + ELECTRIC +} diff --git a/cars/src/main/java/com/mooveit/cars/domain/Image.java b/cars/src/main/java/com/mooveit/cars/domain/Image.java new file mode 100644 index 0000000..3dcdbb6 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/Image.java @@ -0,0 +1,83 @@ +package com.mooveit.cars.domain; + +import javax.persistence.Entity; +import javax.persistence.Lob; +import javax.persistence.OneToOne; +import javax.persistence.Table; +import javax.persistence.Transient; +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +@Entity +@Table(name = "files") +public class Image extends BaseEntity { + + @NotNull + private String fileName; + + @NotNull + private String fileType; + + @Lob + private byte[] data; + + private Long size; + + @Transient + private String url; + + public Image() { + super(); + } + + public Image(@NotNull String fileName, @NotNull String fileType, byte[] data, Long size) { + super(); + this.fileName = fileName; + this.fileType = fileType; + this.data = data; + this.size = size; + } + + @JsonIgnore + public byte[] getData() { + return data; + } + + public void setData(byte[] data) { + this.data = data; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getFileType() { + return fileType; + } + + public void setFileType(String fileType) { + this.fileType = fileType; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + +} \ No newline at end of file diff --git a/cars/src/main/java/com/mooveit/cars/domain/Ingestion.java b/cars/src/main/java/com/mooveit/cars/domain/Ingestion.java new file mode 100644 index 0000000..ea2a36f --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/Ingestion.java @@ -0,0 +1,87 @@ +package com.mooveit.cars.domain; + +import java.util.Date; + +import javax.persistence.Entity; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import javax.validation.constraints.NotNull; + +/** + * Register every ingestion made for a {@link Brand} from a specific `source`. + *
+ * Sources could be URIs, Paths, etc. + */ +@Entity +@Table(name = "ingestion") +public class Ingestion extends BaseEntity { + + @NotNull + @ManyToOne + private Brand brand; + + @NotNull + private Date date; + + @NotNull + private String source; + + private Long totalSpecs; + + private Long newSpecs; + + public Ingestion() { + super(); + } + + public Ingestion(Brand brand, Date date, String source, Long totalSpecs, Long newSpecs) { + super(); + this.brand = brand; + this.date = date; + this.source = source; + this.totalSpecs = totalSpecs; + this.newSpecs = newSpecs; + } + + public Date getDate() { + return date; + } + + public void setDate(Date date) { + this.date = date; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Long getTotalSpecs() { + return totalSpecs; + } + + public void setTotalSpecs(Long totalSpecs) { + this.totalSpecs = totalSpecs; + } + + public Long getNewSpecs() { + return newSpecs; + } + + public void setNewSpecs(Long newSpecs) { + this.newSpecs = newSpecs; + } + + public Brand getBrand() { + return brand; + } + + public void setBrand(Brand brand) { + this.brand = brand; + } + + +} diff --git a/cars/src/main/java/com/mooveit/cars/domain/Modification.java b/cars/src/main/java/com/mooveit/cars/domain/Modification.java new file mode 100644 index 0000000..9ab7e2b --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/Modification.java @@ -0,0 +1,39 @@ +package com.mooveit.cars.domain; + +import javax.persistence.DiscriminatorValue; +import javax.persistence.Entity; +import javax.validation.constraints.NotNull; + +/** + * Entity to model specific variants for a car {@link Specification}, as for example + * different {@link Engine} or {@link Wheel}.  + */ +@Entity +@DiscriminatorValue("modification") +public class Modification extends AbstractSpec { + + /** + * Specification car line + */ + @NotNull + private String line; + + protected Modification() { + super(); + } + + public Modification(@NotNull String name, @NotNull Integer from, Integer to, String line, Engine engine, + Wheel wheel) { + super(name, from, to, engine, wheel); + this.line = line; + } + + public String getLine() { + return line; + } + + public void setLine(String line) { + this.line = line; + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/domain/Specification.java b/cars/src/main/java/com/mooveit/cars/domain/Specification.java new file mode 100644 index 0000000..13a68f6 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/Specification.java @@ -0,0 +1,89 @@ +package com.mooveit.cars.domain; + +import java.util.ArrayList; +import java.util.List; + +import javax.persistence.CascadeType; +import javax.persistence.DiscriminatorValue; +import javax.persistence.Entity; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.validation.constraints.NotNull; + +/** + * Base setup for a car model. + * + */ +@Entity +@DiscriminatorValue("specification") +public class Specification extends AbstractSpec { + + /** + * Cars model type + */ + @NotNull + private String type; + + /** + * Car model brand + */ + @ManyToOne + @NotNull + private Brand brand; + + /** + * List of modifications per each car model + */ + @OneToMany(cascade = CascadeType.ALL) + private List modifications; + + protected Specification() { + super(); + } + + public Specification(Brand brand, String name, Integer from, Integer to, String type, Engine engine, Wheel wheel) { + super(name, from, to, engine, wheel); + this.type = type; + this.modifications = new ArrayList(); + this.brand = brand; + } + + public void addModification(Modification modification) { + this.modifications.add(modification); + } + + public Modification getModification(int index) { + return this.modifications.get(index); + } + + public boolean hasModifications() { + return !this.modifications.isEmpty(); + } + + + public List getModifications() { + return modifications; + } + + public void setModifications(List modifications) { + this.modifications = modifications; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Brand getBrand() { + return brand; + } + + public void setBrand(Brand brand) { + this.brand = brand; + } + + +} diff --git a/cars/src/main/java/com/mooveit/cars/domain/Wheel.java b/cars/src/main/java/com/mooveit/cars/domain/Wheel.java new file mode 100644 index 0000000..931c162 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/domain/Wheel.java @@ -0,0 +1,48 @@ +package com.mooveit.cars.domain; + +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.validation.constraints.NotNull; + +/** + * The entity to model a car's wheels for each {@link AbstractSpec}. + *
+ * Wheels with the same `size` and `type` will be unique. +*/ +@Entity +@Table(name = "wheel") +public class Wheel extends BaseEntity { + + @NotNull + private String size; + + @NotNull + private String type; + + protected Wheel() { + super(); + } + + public Wheel(String size, String type) { + super(); + this.size = size; + this.type = type; + } + + public String getSize() { + return size; + } + + public String getType() { + return type; + } + + public void setSize(String size) { + this.size = size; + } + + public void setType(String type) { + this.type = type; + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/ingestion/BrandBuilder.java b/cars/src/main/java/com/mooveit/cars/ingestion/BrandBuilder.java new file mode 100644 index 0000000..2fa86b7 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/ingestion/BrandBuilder.java @@ -0,0 +1,29 @@ +package com.mooveit.cars.ingestion; + +import java.util.Map; +import java.util.Set; + +import com.mooveit.cars.domain.Brand; + +public interface BrandBuilder { + + /** + * Factory method which should build a list of {@link Brand} based on + * specific data sources. + * + * @param sourceToOmit + * List of data sources that should be omitted during the Brand's + * build process. + * @return Map with a data source as key and the build {@link Brand} as + * value + */ + Map createBrands(Set sourceToOmit); + + + /** + * Return the name of the {@link Brand} that will be constructed + * @return String with the name of {@link Brand} + */ + String getBrandName(); + +} diff --git a/cars/src/main/java/com/mooveit/cars/ingestion/FordBrandBuilder.java b/cars/src/main/java/com/mooveit/cars/ingestion/FordBrandBuilder.java new file mode 100644 index 0000000..b7d9ad2 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/ingestion/FordBrandBuilder.java @@ -0,0 +1,260 @@ +package com.mooveit.cars.ingestion; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.dom4j.Document; +import org.dom4j.DocumentException; +import org.dom4j.Element; +import org.dom4j.io.SAXReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Engine; +import com.mooveit.cars.domain.EngineType; +import com.mooveit.cars.domain.Modification; +import com.mooveit.cars.domain.Specification; +import com.mooveit.cars.domain.Wheel; + +/** + * Ford brand builder builds a specific {@link Brand} per each file found on the + * base folder. + * + * Sources are used to filter these files based on certain criteria, as for + * examples old, temporary or already processed files. + * + * The .xml format follow the next structure : + * + *
+ *{@code
+ * 
+ *    
+ *        
+ *        
+ *        
+ *            
+ *                
+ *                
+ *            
+ *            
+ *                
+ *                
+ *            
+ *        
+ *    
+ *    ...
+ *
+ *}
+ * 
+ */ +public class FordBrandBuilder implements BrandBuilder { + + private static final Logger log = LoggerFactory.getLogger(FordBrandBuilder.class); + + /** + * Folder to scan + */ + private String scanDirectory = "."; + + /** + * Scan folder on classloader or file system + */ + private boolean scanClassloader = true; + + public FordBrandBuilder() { + super(); + } + + public FordBrandBuilder(String scanDirectory, boolean scanClassloader) { + super(); + this.scanDirectory = scanDirectory; + this.scanClassloader = scanClassloader; + } + + /** + * Brand name + */ + @Override + public String getBrandName() { + return "Ford"; + } + + /** + * Build a {@link Brand} per each `.xml` file found in the specific + * `fordFilesDir` folder. + */ + @Override + public Map createBrands(Set sourceToOmit) { + + Map brands = new HashMap(); + try { + + List files = this.getSourceXMLFiles(sourceToOmit); + for (File file : files) { + InputStream is = new FileInputStream(file); + brands.put(file.toString(), this.createBrandFromXml(is)); + } + + } catch (Exception e) { + String errorMessage = String.format( + "There was a problem gathering files from directory %s with pattern ford-*.xml", + this.scanDirectory); + log.error(errorMessage); + throw new IngestionException(errorMessage, e); + } + + return brands; + + } + + protected List getSourceXMLFiles(Set sourceToOmit) { + + String filePattern = String.format("^ford-(.+).xml$", File.separator); + try { + // Scan directory + Path basePath = null; + if (this.scanClassloader) { + basePath = Paths.get(getClass().getClassLoader().getResource(this.scanDirectory).toURI()); + } else { + basePath = Paths.get(this.scanDirectory); + } + + // Filter all ford-*.xml files with given format not in + // `sourceToOmit` + Pattern fordXmlFilePattern = Pattern.compile(filePattern); + return Files.list(basePath).filter(p -> p.toFile().isFile()) + .filter(p -> fordXmlFilePattern.matcher(p.getFileName().toString()).matches()) + .filter(p -> !sourceToOmit.contains(p.toAbsolutePath().toString())).map(p -> p.toFile()) + .collect(Collectors.toList()); + } catch (Exception e) { + String errorMessage = String.format( + "There was a probles scanning files from directory %s with pattern `%s`", this.scanDirectory, + filePattern); + log.error(errorMessage); + throw new IngestionException(errorMessage, e); + } + } + + /** + * Build a {@link Brand} based on a XML which path is given by parameter. + * + * @param path + * Path of the XML file to be parsed + * @return {@link Brand} built based on XML file given as argument + */ + protected Brand createBrandFromXml(InputStream is) { + + try { + + log.debug(String.format("Processing Ford catalog")); + + SAXReader reader = new SAXReader(); + Document document = reader.read(is); + + // Create Ford Brand + Brand brand = new Brand("Ford"); + + log.debug("Start processing CATALOG element ..."); + Element catalog = document.getRootElement(); + if (catalog.getName().equals("CATALOG")) { + throw new Error("Root element name must be `CATALOG`"); + } + + log.debug("Start processing CATALOG/MODEL list ..."); + Iterator iterator = catalog.elementIterator("MODEL"); + while (iterator.hasNext()) { + Specification spec = this.buildSpecification(brand, (Element) iterator.next()); + brand.addSpecification(spec); + } + log.debug(String.format("Specifications : %d created", brand.getSpecifications().count())); + + return brand; + + } catch (DocumentException e) { + String errorMessage = String.format("Failed reading source InputStream"); + log.error(errorMessage); + throw new IngestionException(errorMessage, e); + } + + } + + /** + * Based on a `MODEL` element, create a {@link Specification} and all their + * {@link Modification}. + */ + private Specification buildSpecification(Brand brand, Element element) { + + String name = element.attributeValue("name"); + String type = element.attributeValue("type"); + Integer from = attributeIntValue(element, "from"); + Integer to = attributeIntValue(element, "to"); + Engine engine = this.buildEngine(element.element("ENGINE")); + Wheel wheel = this.buildWheel(element.element("WHEELS")); + + Specification spec = new Specification(brand, name, from, to, type, engine, wheel); + log.debug(String.format("Specification '%s' was created", spec.getName())); + + Element submodels = element.element("SUBMODELS"); + if (submodels != null) { + Iterator iterator = submodels.elementIterator("MODEL"); + while (iterator.hasNext()) { + Modification modif = this.buildModification((Element) iterator.next()); + spec.addModification(modif); + } + } + log.debug(String.format("Modifications : %d created", spec.getModifications().size())); + + return spec; + } + + private Modification buildModification(Element element) { + if (element == null) { + return null; + } + + String name = element.attributeValue("name"); + String line = element.attributeValue("line"); + Integer from = attributeIntValue(element, "from"); + Integer to = attributeIntValue(element, "to"); + Engine engine = this.buildEngine(element.element("ENGINE")); + Wheel wheel = this.buildWheel(element.element("WHEELS")); + + return new Modification(name, from, to, line, engine, wheel); + } + + private Wheel buildWheel(Element element) { + if (element == null) { + return null; + } + String size = element.attributeValue("size"); + String type = element.attributeValue("type"); + return new Wheel(size, type); + } + + private Engine buildEngine(Element element) { + if (element == null) { + return null; + } + Integer size = attributeIntValue(element, "power"); + EngineType type = element.attributeValue("type") == null ? null + : EngineType.valueOf(element.attributeValue("type")); + return new Engine(size, type); + } + + private Integer attributeIntValue(Element element, String attrName) { + return element.attributeValue(attrName) != null ? Integer.valueOf(element.attributeValue(attrName)) : null; + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/ingestion/IngestStrategy.java b/cars/src/main/java/com/mooveit/cars/ingestion/IngestStrategy.java new file mode 100644 index 0000000..215a7b2 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/ingestion/IngestStrategy.java @@ -0,0 +1,13 @@ +package com.mooveit.cars.ingestion; + +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Ingestion; + +/** + * Strategy to ingest a new {@link Brand} + */ +public interface IngestStrategy { + + Ingestion ingest(String source, Brand brandToIngest); + +} \ No newline at end of file diff --git a/cars/src/main/java/com/mooveit/cars/ingestion/IngestionException.java b/cars/src/main/java/com/mooveit/cars/ingestion/IngestionException.java new file mode 100644 index 0000000..35cfae4 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/ingestion/IngestionException.java @@ -0,0 +1,15 @@ +package com.mooveit.cars.ingestion; + +public class IngestionException extends RuntimeException { + + private static final long serialVersionUID = 612343670266273279L; + + public IngestionException() { + super(); + } + + public IngestionException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/ingestion/MergeBrandsIngestStrategy.java b/cars/src/main/java/com/mooveit/cars/ingestion/MergeBrandsIngestStrategy.java new file mode 100644 index 0000000..93a6791 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/ingestion/MergeBrandsIngestStrategy.java @@ -0,0 +1,53 @@ +package com.mooveit.cars.ingestion; + +import java.util.Date; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Ingestion; +import com.mooveit.cars.domain.Specification; +import com.mooveit.cars.repositories.BrandRepository; + +/** + * This strategy takes a transient {@link Brand} and: + *
    + *
  1. If the {@link Brand} doesn't exists into the repository, save it as it is + *
  2. If the {@link Brand} exists, for each {@link Specification}: + *
  3. If {@link Specification} doesn't exists, add it to the {@link Brand} + *
  4. If {@link Specification} exists, this will be replaced for the new one + *
+ */ +@Service +public class MergeBrandsIngestStrategy implements IngestStrategy { + + @Autowired + private BrandRepository brandRepo; + + /* + * @see com.mooveit.cars.ingestion.IngestStrategy#ingest(java.lang.String, + * com.mooveit.cars.domain.Brand) + */ + @Override + public Ingestion ingest(String source, Brand brandToIngest) { + + Optional persistentBrand = brandRepo.findByName(brandToIngest.getName()); + + if (!persistentBrand.isPresent()) { + // If brand didn't exists, persist complete new brand + brandToIngest = brandRepo.save(brandToIngest); + Long totalSpecs = brandToIngest.getSpecifications().count(); + return new Ingestion(brandToIngest, new Date(), source, totalSpecs, totalSpecs); + } else { + + // TODO : If brand exists, persist new Specifications and update + // existent ones. + + } + return new Ingestion(); + + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/repositories/AbstractSpecRepository.java b/cars/src/main/java/com/mooveit/cars/repositories/AbstractSpecRepository.java new file mode 100644 index 0000000..634b3c2 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/repositories/AbstractSpecRepository.java @@ -0,0 +1,11 @@ +package com.mooveit.cars.repositories; + +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Repository; + +import com.mooveit.cars.domain.AbstractSpec; + +@Repository +public interface AbstractSpecRepository extends PagingAndSortingRepository { + +} \ No newline at end of file diff --git a/cars/src/main/java/com/mooveit/cars/repositories/BrandRepository.java b/cars/src/main/java/com/mooveit/cars/repositories/BrandRepository.java new file mode 100644 index 0000000..cba2e6b --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/repositories/BrandRepository.java @@ -0,0 +1,15 @@ +package com.mooveit.cars.repositories; + +import java.util.Optional; + +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Repository; + +import com.mooveit.cars.domain.Brand; + +@Repository +public interface BrandRepository extends PagingAndSortingRepository { + + public Optional findByName(String name); + +} diff --git a/cars/src/main/java/com/mooveit/cars/repositories/EngineRepository.java b/cars/src/main/java/com/mooveit/cars/repositories/EngineRepository.java new file mode 100644 index 0000000..ac3a1b4 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/repositories/EngineRepository.java @@ -0,0 +1,20 @@ +package com.mooveit.cars.repositories; + +import java.util.Optional; + +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.rest.core.annotation.RestResource; +import org.springframework.stereotype.Repository; + +import com.mooveit.cars.domain.Engine; +import com.mooveit.cars.domain.EngineType; + +@Repository +@RestResource(exported = false) +public interface EngineRepository extends PagingAndSortingRepository { + + public Optional findByPowerAndType(Integer power, EngineType type); + + public Optional findByPower(Integer power); + +} diff --git a/cars/src/main/java/com/mooveit/cars/repositories/IngestionDTO.java b/cars/src/main/java/com/mooveit/cars/repositories/IngestionDTO.java new file mode 100644 index 0000000..6afa785 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/repositories/IngestionDTO.java @@ -0,0 +1,6 @@ +package com.mooveit.cars.repositories; + +public interface IngestionDTO { + + String getSource(); +} diff --git a/cars/src/main/java/com/mooveit/cars/repositories/IngestionRepository.java b/cars/src/main/java/com/mooveit/cars/repositories/IngestionRepository.java new file mode 100644 index 0000000..bf8de9a --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/repositories/IngestionRepository.java @@ -0,0 +1,19 @@ +package com.mooveit.cars.repositories; + +import java.util.Date; +import java.util.Optional; +import java.util.Set; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import com.mooveit.cars.domain.Ingestion; + +@Repository +public interface IngestionRepository extends CrudRepository { + + public Optional findBySourceAndDate(String source, Date date); + + public Set findAllByBrandName(String brandName); + +} diff --git a/cars/src/main/java/com/mooveit/cars/repositories/SpecificationRepository.java b/cars/src/main/java/com/mooveit/cars/repositories/SpecificationRepository.java new file mode 100644 index 0000000..e106cf7 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/repositories/SpecificationRepository.java @@ -0,0 +1,20 @@ +package com.mooveit.cars.repositories; + +import java.util.List; + +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.mooveit.cars.domain.EngineType; +import com.mooveit.cars.domain.Specification; + +@Repository +public interface SpecificationRepository extends PagingAndSortingRepository { + + public List findByName(@Param(value = "name") String name); + + public List findByEngineType(EngineType name); + + public List findByBrandName(@Param(value = "brand") String name); +} diff --git a/cars/src/main/java/com/mooveit/cars/repositories/WheelRepository.java b/cars/src/main/java/com/mooveit/cars/repositories/WheelRepository.java new file mode 100644 index 0000000..71214f7 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/repositories/WheelRepository.java @@ -0,0 +1,17 @@ +package com.mooveit.cars.repositories; + +import java.util.Optional; + +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.rest.core.annotation.RestResource; +import org.springframework.stereotype.Repository; + +import com.mooveit.cars.domain.Wheel; + +@Repository +@RestResource(exported = false) +public interface WheelRepository extends PagingAndSortingRepository { + + public Optional findBySizeAndType(String R15, String type); + +} diff --git a/cars/src/main/java/com/mooveit/cars/services/ImageService.java b/cars/src/main/java/com/mooveit/cars/services/ImageService.java new file mode 100644 index 0000000..01df220 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/services/ImageService.java @@ -0,0 +1,45 @@ +package com.mooveit.cars.services; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import com.mooveit.cars.domain.AbstractSpec; +import com.mooveit.cars.domain.Image; +import com.mooveit.cars.repositories.AbstractSpecRepository; + +@Service +public class ImageService { + + @Autowired + private AbstractSpecRepository abstractSpecRepository; + + public Image storeFile(Long specId, MultipartFile file) { + + // Normalize file name + String fileName = StringUtils.cleanPath(file.getOriginalFilename()); + + try { + + AbstractSpec spec = abstractSpecRepository.findById(specId).orElseThrow(() -> new ImageStorageException( + String.format("Parent specification with id %d is doesn't exists", specId))); + + Image newImage = new Image(fileName, file.getContentType(), file.getBytes(), file.getSize()); + spec.setImage(newImage); + + spec = abstractSpecRepository.save(spec); + return spec.getImage(); + + } catch (IOException ex) { + throw new ImageStorageException("Could not store image " + fileName + ". Please try again!", ex); + } + } + + public Image getFile(Long specId) { + return abstractSpecRepository.findById(specId).map(spec -> spec.getImage()) + .orElseThrow(() -> new ImageStorageException("Image not found for Specification id " + specId)); + } +} diff --git a/cars/src/main/java/com/mooveit/cars/services/ImageStorageException.java b/cars/src/main/java/com/mooveit/cars/services/ImageStorageException.java new file mode 100644 index 0000000..54d3817 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/services/ImageStorageException.java @@ -0,0 +1,13 @@ +package com.mooveit.cars.services; + +public class ImageStorageException extends RuntimeException { + + public ImageStorageException(String message, Throwable cause) { + super(message, cause); + } + + public ImageStorageException(String message) { + super(message); + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/tasks/BuildersConfigurations.java b/cars/src/main/java/com/mooveit/cars/tasks/BuildersConfigurations.java new file mode 100644 index 0000000..b291d34 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/tasks/BuildersConfigurations.java @@ -0,0 +1,23 @@ +package com.mooveit.cars.tasks; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.mooveit.cars.ingestion.BrandBuilder; +import com.mooveit.cars.ingestion.FordBrandBuilder; + +@Configuration +public class BuildersConfigurations { + + public class CollectionConfig { + + @Bean + public List brandBuilders() { + return Arrays.asList(new FordBrandBuilder()); + } + } + +} diff --git a/cars/src/main/java/com/mooveit/cars/tasks/FordIngesterTask.java b/cars/src/main/java/com/mooveit/cars/tasks/FordIngesterTask.java deleted file mode 100644 index a04f791..0000000 --- a/cars/src/main/java/com/mooveit/cars/tasks/FordIngesterTask.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.mooveit.cars.tasks; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -public class FordIngesterTask { - - @Scheduled(cron = "${cars.ford.ingester.runCron}") - public void ingestFile() { - log.warn("Not implemented yet."); - } -} diff --git a/cars/src/main/java/com/mooveit/cars/tasks/IngesterTask.java b/cars/src/main/java/com/mooveit/cars/tasks/IngesterTask.java new file mode 100644 index 0000000..7b97bc4 --- /dev/null +++ b/cars/src/main/java/com/mooveit/cars/tasks/IngesterTask.java @@ -0,0 +1,93 @@ +package com.mooveit.cars.tasks; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Ingestion; +import com.mooveit.cars.ingestion.BrandBuilder; +import com.mooveit.cars.ingestion.IngestStrategy; +import com.mooveit.cars.repositories.IngestionDTO; +import com.mooveit.cars.repositories.IngestionRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class IngesterTask { + + private static final Logger log = LoggerFactory.getLogger(IngesterTask.class); + + @Autowired + private IngestStrategy strategy; + + @Autowired + private IngestionRepository ingestionRepo; + + @Autowired + private List brandBuilders; + + public IngesterTask() { + super(); + } + + @Scheduled(cron = "${cars.ford.ingester.runCron}") + public void ingestBrands() { + + for (BrandBuilder builder : brandBuilders) { + + String brandName = builder.getBrandName(); + log.debug("Retrieving already imported sources for brand %s", brandName); + Set ingestions = ingestionRepo.findAllByBrandName(brandName); + Set sourcesToOmit = ingestions.stream().map(idto -> idto.getSource()).collect(Collectors.toSet()); + + if (log.isDebugEnabled()) { + String omitedSources = sourcesToOmit.stream().collect(Collectors.joining(", ")); + log.debug("Creating all non imported catalogs, excluding [%d] ", omitedSources); + } + + log.debug("Creating brands for %s ", brandName); + Map brands = builder.createBrands(sourcesToOmit); + for (String source : brands.keySet()) { + + log.debug("Ingesting %s's brand from source %s", brandName, source); + Ingestion ingestion = strategy.ingest(source, brands.get(source)); + ingestionRepo.save(ingestion); + } + } + + } + + public IngestStrategy getStrategy() { + return strategy; + } + + public void setStrategy(IngestStrategy strategy) { + this.strategy = strategy; + } + + public IngestionRepository getIngestionRepo() { + return ingestionRepo; + } + + public void setIngestionRepo(IngestionRepository ingestionRepo) { + this.ingestionRepo = ingestionRepo; + } + + public List getBrandBuilders() { + return brandBuilders; + } + + public void setBrandBuilders(List brandBuilders) { + this.brandBuilders = brandBuilders; + } + +} diff --git a/cars/src/main/resources/application.yml b/cars/src/main/resources/application.yml index 436f591..00064cc 100644 --- a/cars/src/main/resources/application.yml +++ b/cars/src/main/resources/application.yml @@ -1,14 +1,64 @@ -cars: - ford: - ingester: - runCron: '0 * * ? * *' #each minute - spring: + profiles.active: dev + main.allow-bean-definition-overriding : true + +--- +spring: + profiles: dev + datasource: - url: jdbc:h2:mem:carsdb + url: jdbc:h2:mem:carsdb;DB_CLOSE_ON_EXIT=FALSE driverClassName: org.h2.Driver username: sa - password: + password: null + jpa: - database-platform: 'org.hibernate.dialect.H2Dialect' - h2.console.enabled: true + database-platform: org.hibernate.dialect.H2Dialect + properties: + hibernate: + ddl-auto: create-drop + show_sql: false + use_sql_comments: false + format_sql: false + h2: + console: + enabled: true + path: /console + settings: + trace: false + web-allow-others: false + +cars: + ford: + ingester: + runCron: 0 * * * * ? + +logging: + file: logs/dev_app.log + pattern: + console: '%d %-5level %logger : %msg%n' + file: '%d %-5level [%thread] %logger : %msg%n' + level: + org.springframework.web: DEBUG + guru.springframework.controllers: DEBUG + org.hibernate: DEBUG + +--- +spring: + profiles: prod + +cars: + ford: + ingester: + runCron: 0 * * ? * * + +logging: + file: logs/dev_app.log + pattern: + console: '%d %-5level %logger : %msg%n' + file: '%d %-5level [%thread] %logger : %msg%n' + level: + org.springframework.web: WARN + guru.springframework.controllers: WARN + org.hibernate: WARN + \ No newline at end of file diff --git a/cars/src/test/java/com/mooveit/cars/CarsApplicationTests.java b/cars/src/test/java/com/mooveit/cars/CarsApplicationTests.java deleted file mode 100644 index 426b96d..0000000 --- a/cars/src/test/java/com/mooveit/cars/CarsApplicationTests.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.mooveit.cars; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; - -@RunWith(SpringRunner.class) -@SpringBootTest -public class CarsApplicationTests { - - @Test - public void contextLoads() { - } - -} diff --git a/cars/src/test/java/com/mooveit/cars/ingestion/FordBrandBuilderTest.java b/cars/src/test/java/com/mooveit/cars/ingestion/FordBrandBuilderTest.java new file mode 100644 index 0000000..8bfa7ab --- /dev/null +++ b/cars/src/test/java/com/mooveit/cars/ingestion/FordBrandBuilderTest.java @@ -0,0 +1,75 @@ +package com.mooveit.cars.ingestion; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.regex.Pattern; + +import org.junit.Test; + +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Engine; + +public class FordBrandBuilderTest { + + @Test + public void testCreateBrandFromXmlFile() { + + FordBrandBuilder builder = new FordBrandBuilder(); + + InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("ford-test-example.xml"); + + Brand brand = builder.createBrandFromXml(inputStream); + assertNotNull(brand); + + brand.getSpecifications().forEach(x -> assertNotNull(x.getEngine())); + brand.getSpecifications().forEach(x -> assertNotNull(x.getWheel())); + } + + @Test + public void testCreateBrandFromXmlString() { + + FordBrandBuilder builder = new FordBrandBuilder(); + + String xml = String.join("/n", + "" + "", "", + "", ""); + + InputStream inputStream = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)); + + Brand brand = builder.createBrandFromXml(inputStream); + assertNotNull(brand); + + Engine eng = brand.getSpecifications().findFirst().get().getEngine(); + + // Empty Engine + assertNotNull(eng); + assertNull(eng.getType()); + assertNull(eng.getPower()); + + // Null Wheel + assertNull(brand.getSpecifications().findFirst().get().getWheel()); + + } + + @Test + public void testGetSourceXMLFiles() { + + FordBrandBuilder builder = new FordBrandBuilder(); + + assertTrue(Pattern.compile(String.format("^(.+)%sford-(.+).xml$", File.separator)) + .matcher("/User/nicolas/ford-anytexthere.xml").matches()); + + List testFiles = builder.getSourceXMLFiles(new HashSet<>()); + assertTrue(testFiles.size() == 1); + + } + +} diff --git a/cars/src/test/java/com/mooveit/cars/repositories/AbtractSpecRepositoryTest.java b/cars/src/test/java/com/mooveit/cars/repositories/AbtractSpecRepositoryTest.java new file mode 100644 index 0000000..d3cc6ed --- /dev/null +++ b/cars/src/test/java/com/mooveit/cars/repositories/AbtractSpecRepositoryTest.java @@ -0,0 +1,70 @@ +package com.mooveit.cars.repositories; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Optional; + +import javax.transaction.Transactional; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import com.mooveit.cars.domain.AbstractSpec; +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Engine; +import com.mooveit.cars.domain.EngineType; +import com.mooveit.cars.domain.Modification; +import com.mooveit.cars.domain.Specification; + +@RunWith(SpringRunner.class) +@SpringBootTest +@Transactional +public class AbtractSpecRepositoryTest { + + @Autowired + private SpecificationRepository specRepository; + + @Autowired + private AbstractSpecRepository abstractRepository; + + @Autowired + private BrandRepository brandRepository; + + private Specification createCarSpec(boolean withModifications) { + Brand brand = new Brand("Ford"); + brand = brandRepository.save(brand); + + Engine eng = new Engine(100, EngineType.HYBRID); + Specification spec = new Specification(brand,"Specification A", 1994, 1996, "subcompact", eng, null); + + if (withModifications){ + spec.addModification(new Modification("Modification A.1", 1900, 1950, "high-line", null, null)); + } + return specRepository.save(spec); + } + + + @Test + public void testSaveAndDeleteSpecificationWitnModifications() throws Exception { + Specification savedSpec = createCarSpec(true); + + // Check collection was persisted using cascade strategy + assertTrue(savedSpec.hasModifications()); + Modification savedModif = savedSpec.getModification(0); + assertNotNull(savedModif.getId()); + + Iterable iterSpecs = abstractRepository.findAllById(Arrays.asList(savedSpec.getId(), savedModif.getId())); + for (AbstractSpec abstractSpec : iterSpecs) { + assertNotNull(abstractSpec.getId()); + } + + } + + +} diff --git a/cars/src/test/java/com/mooveit/cars/repositories/AllRepositoryTests.java b/cars/src/test/java/com/mooveit/cars/repositories/AllRepositoryTests.java new file mode 100644 index 0000000..55a9437 --- /dev/null +++ b/cars/src/test/java/com/mooveit/cars/repositories/AllRepositoryTests.java @@ -0,0 +1,14 @@ +package com.mooveit.cars.repositories; + +import org.junit.runner.RunWith; +import org.junit.runners.Suite; +import org.junit.runners.Suite.SuiteClasses; + +@RunWith(Suite.class) +@SuiteClasses({ EngineRepositoryTest.class, + SpecificationRepositoryTest.class, + WheelRepositoryTest.class, + IngestionRepositoryTest.class}) +public class AllRepositoryTests { + +} diff --git a/cars/src/test/java/com/mooveit/cars/repositories/EngineRepositoryTest.java b/cars/src/test/java/com/mooveit/cars/repositories/EngineRepositoryTest.java new file mode 100644 index 0000000..47abd9f --- /dev/null +++ b/cars/src/test/java/com/mooveit/cars/repositories/EngineRepositoryTest.java @@ -0,0 +1,53 @@ +package com.mooveit.cars.repositories; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Optional; + +import javax.transaction.Transactional; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import com.mooveit.cars.domain.Engine; +import com.mooveit.cars.domain.EngineType; + +@RunWith(SpringRunner.class) +@SpringBootTest +@Transactional +public class EngineRepositoryTest { + + @Autowired + private EngineRepository engineRepository; + + @Test + public void testSaveAndDeleteSingleEngine() throws Exception { + + Engine savedEngine = engineRepository.save( new Engine(1400, EngineType.GAS)); + + Long entityId = savedEngine.getId(); + assertTrue(engineRepository.existsById(entityId)); + assertTrue(engineRepository.findById(entityId).isPresent()); + + engineRepository.deleteById(savedEngine.getId()); + + assertFalse(engineRepository.existsById(entityId)); + assertFalse(engineRepository.findById(entityId).isPresent()); + + } + + @Test + public void testFindBySizeAndType() throws Exception { + + engineRepository.save( new Engine(1400, EngineType.GAS)); + + Optional resultList = engineRepository.findByPower(1400); + assertTrue(resultList.isPresent()); + + } + +} diff --git a/cars/src/test/java/com/mooveit/cars/repositories/IngestionRepositoryTest.java b/cars/src/test/java/com/mooveit/cars/repositories/IngestionRepositoryTest.java new file mode 100644 index 0000000..31834e2 --- /dev/null +++ b/cars/src/test/java/com/mooveit/cars/repositories/IngestionRepositoryTest.java @@ -0,0 +1,66 @@ +package com.mooveit.cars.repositories; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Date; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.transaction.Transactional; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Ingestion; + +@RunWith(SpringRunner.class) +@SpringBootTest +@Transactional +public class IngestionRepositoryTest { + + @Autowired + private IngestionRepository ingestionRepository; + + @Autowired + private BrandRepository brandRepository; + + @Test + public void testSaveAndDeleteSingleIngestion() throws Exception { + + Brand brand = brandRepository.save(new Brand("Ford")); + Ingestion ingestion = new Ingestion(brand, new Date(), "fileName.xml", 10L, 10L); + + Ingestion savedIng = ingestionRepository.save(ingestion); + + Long entityId = savedIng.getId(); + assertTrue(ingestionRepository.existsById(entityId)); + assertTrue(ingestionRepository.findById(entityId).isPresent()); + + ingestionRepository.delete(savedIng); + + assertFalse(ingestionRepository.existsById(entityId)); + assertFalse(ingestionRepository.findById(entityId).isPresent()); + + } + + @Test + public void testFindAllByBrandName() throws Exception { + + Brand brand = brandRepository.save(new Brand("Ford")); + Ingestion ingestion = new Ingestion(brand, new Date(), "fileName.xml", 10L, 10L); + + Ingestion savedIng = ingestionRepository.save(ingestion); + + Set ingestionSet = ingestionRepository.findAllByBrandName("Ford"); + assertFalse(ingestionSet.isEmpty()); + assertTrue(ingestionSet.stream().map(i -> i.getSource()).collect(Collectors.toSet()) + .contains(savedIng.getSource())); + + } + +} diff --git a/cars/src/test/java/com/mooveit/cars/repositories/SpecificationRepositoryTest.java b/cars/src/test/java/com/mooveit/cars/repositories/SpecificationRepositoryTest.java new file mode 100644 index 0000000..c1788dd --- /dev/null +++ b/cars/src/test/java/com/mooveit/cars/repositories/SpecificationRepositoryTest.java @@ -0,0 +1,92 @@ +package com.mooveit.cars.repositories; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import javax.transaction.Transactional; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Engine; +import com.mooveit.cars.domain.EngineType; +import com.mooveit.cars.domain.Modification; +import com.mooveit.cars.domain.Specification; + +@RunWith(SpringRunner.class) +@SpringBootTest +@Transactional +public class SpecificationRepositoryTest { + + @Autowired + private SpecificationRepository specRepository; + + @Autowired + private BrandRepository brandRepository; + + private Specification createCarSpec(boolean withModifications) { + Brand brand = new Brand("Ford"); + brand = brandRepository.save(brand); + + Engine eng = new Engine(100, EngineType.HYBRID); + Specification spec = new Specification(brand,"Specification A", 1994, 1996, "subcompact", eng, null); + + if (withModifications){ + spec.addModification(new Modification("Modification A.1", 1900, 1950, "high-line", null, null)); + } + return specRepository.save(spec); + } + + @Test + public void testSaveAndDeleteSingleSpec() throws Exception { + Specification savedSpec = createCarSpec(false); + + Long entityId = savedSpec.getId(); + assertTrue(specRepository.existsById(entityId)); + assertTrue(specRepository.findById(entityId).isPresent()); + + specRepository.deleteById(entityId); + + assertFalse(specRepository.existsById(entityId)); + assertFalse(specRepository.findById(entityId).isPresent()); + + } + + + @Test + public void testSaveAndDeleteSpecificationWitnModifications() throws Exception { + Specification savedSpec = createCarSpec(true); + + // Check collection was persisted using cascade strategy + assertTrue(savedSpec.hasModifications()); + Modification savedModif = savedSpec.getModification(0); + assertNotNull(savedModif.getId()); + + // Check all entities are removed using cascade strategy + specRepository.deleteById(savedSpec.getId()); + assertFalse(specRepository.existsById(savedSpec.getId())); + assertFalse(specRepository.existsById(savedModif.getId())); + } + + @Test + public void testFindByName() throws Exception { + Specification savedSpec = createCarSpec(false); + + Iterable result = specRepository.findByName(savedSpec.getName()); + assertTrue(result.iterator().hasNext()); + } + + @Test + public void testFindByEngineType() throws Exception { + Specification savedSpec = createCarSpec(false); + + Iterable result = specRepository.findByEngineType(savedSpec.getEngine().getType()); + assertTrue(result.iterator().hasNext()); + } + +} diff --git a/cars/src/test/java/com/mooveit/cars/repositories/WheelRepositoryTest.java b/cars/src/test/java/com/mooveit/cars/repositories/WheelRepositoryTest.java new file mode 100644 index 0000000..4a2791c --- /dev/null +++ b/cars/src/test/java/com/mooveit/cars/repositories/WheelRepositoryTest.java @@ -0,0 +1,52 @@ +package com.mooveit.cars.repositories; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Optional; + +import javax.transaction.Transactional; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import com.mooveit.cars.domain.Wheel; + +@RunWith(SpringRunner.class) +@SpringBootTest +@Transactional +public class WheelRepositoryTest { + + @Autowired + private WheelRepository wheelRepository; + + @Test + public void testSaveAndDeleteSingleWheel() throws Exception { + + Wheel savedWheel = wheelRepository.save(new Wheel("R15", "STEEL")); + + Long entityId = savedWheel.getId(); + assertTrue(wheelRepository.existsById(entityId)); + assertTrue(wheelRepository.findById(entityId).isPresent()); + + wheelRepository.deleteById(savedWheel.getId()); + + assertFalse(wheelRepository.existsById(entityId)); + assertFalse(wheelRepository.findById(entityId).isPresent()); + + } + + @Test + public void testFindBySizeAndType() throws Exception { + + wheelRepository.save(new Wheel("R15", "STEEL")); + + Optional resultList = wheelRepository.findBySizeAndType("R15", "STEEL"); + assertTrue(resultList.isPresent()); + + } + +} diff --git a/cars/src/test/java/com/mooveit/cars/tasks/IngesterTaskTest.java b/cars/src/test/java/com/mooveit/cars/tasks/IngesterTaskTest.java new file mode 100644 index 0000000..1bb3106 --- /dev/null +++ b/cars/src/test/java/com/mooveit/cars/tasks/IngesterTaskTest.java @@ -0,0 +1,95 @@ +package com.mooveit.cars.tasks; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.eq; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit4.SpringRunner; + +import com.mooveit.cars.domain.Brand; +import com.mooveit.cars.domain.Ingestion; +import com.mooveit.cars.ingestion.BrandBuilder; +import com.mooveit.cars.ingestion.MergeBrandsIngestStrategy; +import com.mooveit.cars.repositories.IngestionDTO; +import com.mooveit.cars.repositories.IngestionRepository; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class IngesterTaskTest { + + @Autowired + IngesterTask task; + + @MockBean + MergeBrandsIngestStrategy strategy; + + @MockBean + IngestionRepository ingestRepository; + + @Autowired + List brandBuilders; + + + private static String testSource = "sample-catalog.xml"; + + private static String testBrandName = "Test Brand"; + + private static Brand brandMock; + + @TestConfiguration + static class TestContextConfiguration { + + @Bean + public List brandBuilders() { + + // Create Mock Builder + Map brands = new HashMap(); + brands.put(testSource, brandMock); + + BrandBuilder bb = mock(BrandBuilder.class); + when(bb.getBrandName()).thenReturn(testBrandName); + when(bb.createBrands(any())).thenReturn(brands); + + return Arrays.asList(bb); + } + } + + @Before + public void setUp(){ + + } + + @Test + public void testIngestFiles() { + + // Empty source to omit + when(ingestRepository.findAllByBrandName(testBrandName)).thenReturn(new HashSet()); + when(strategy.ingest(testSource, brandMock)).thenReturn(mock(Ingestion.class)); + + task.ingestBrands(); + + verify(ingestRepository, times(1)).findAllByBrandName(eq(testBrandName)); + verify(strategy, times(1)).ingest(eq(testSource), eq(brandMock)); + verify(ingestRepository, times(1)).save(any(Ingestion.class)); + + } + +} + diff --git a/cars/src/test/resources/ford-test-example.xml b/cars/src/test/resources/ford-test-example.xml new file mode 100644 index 0000000..1c375a6 --- /dev/null +++ b/cars/src/test/resources/ford-test-example.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file