0

I'm writing my first web application with Spring and Angular. I used PHP for about 10 years, then discovered and used Rails for the last 10 years, and have done a couple ASP projects to boot, so I'm no stranger to web development, in general. I'm trying to create my first complete set of CRUD actions, but I cannot find documentation on how to create an edit form for a "complex" object (with parents and/or children). The official Angular guide stops just short of this, and I can't find a single tutorial anywhere on the internet that covers this comprehensively. In my example, I want an edit component for a "Product" where I can change the currently selected "Engine".

In Spring, I have classes, repositories, and controllers configured for my two initial models, which looks like this:

Engine (parent) -> Product (child)

Product.java:

package com.xyz.cddm_ng;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import javax.persistence.*;
import java.time.LocalDateTime;
import static javax.persistence.GenerationType.IDENTITY;

@Entity
@Getter @Setter
@NoArgsConstructor
@ToString @EqualsAndHashCode
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class Product {
    @Id
    @GeneratedValue(strategy=IDENTITY)
    Long id;

    @NonNull String title;

    String note;

    @CreationTimestamp
    LocalDateTime createDateTime;

    @UpdateTimestamp
    LocalDateTime updateDateTime;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "engine_id")
    @JsonManagedReference
    Engine engine;
}

Engine.java (snippet):

@OneToMany(fetch = FetchType.LAZY, mappedBy = "engine")
@JsonBackReference
Collection<Product> products;

The controller and repositories are just stubbed out.

ProductController.java:

package com.xyz.cddm_ng;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("products")
@CrossOrigin(origins = "http://localhost:4200")
class ProductController { }

ProductRepository.java:

package com.xyz.cddm_ng;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
import org.springframework.web.bind.annotation.CrossOrigin;

@RepositoryRestResource
@CrossOrigin(origins = "http://localhost:4200")
interface ProductRepository extends JpaRepository<Product, Long> { }

On the Angular side, I have product.service.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})

export class ProductService {

  public API = '//localhost:8080';
  public PRODUCT_API = this.API + '/products';

  constructor(private http: HttpClient) { }

  getAll(): Observable<any> {
    return this.http.get(this.PRODUCT_API);
  }

  get(id: string) {
    return this.http.get(this.PRODUCT_API + '/' + id);
  }

  save(product: any): Observable<any> {
    let result: Observable<Object>;
    if (product['href']) {
      result = this.http.put(product.href, product);
    } else {
      result = this.http.post(this.PRODUCT_API, product);
    }
    return result;
  }

  remove(href: string) {
    return this.http.delete(href);
  }

}

product-edit.component.ts:

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { ProductService } from '../shared/product/product.service';
import { EngineService } from '../shared/engine/engine.service';
import { NgForm } from '@angular/forms';

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.component.html',
  styleUrls: ['./product-edit.component.css']
})
export class ProductEditComponent implements OnInit, OnDestroy {
  prod: any = { };

  sub: Subscription;

  engines: any[] = [ ];

  constructor(private route: ActivatedRoute,
              private router: Router,
              private productService: ProductService,
              private engineService: EngineService) { }

  ngOnInit() {
    this.sub = this.route.params.subscribe( params => {
      const id = params['id'];
      if (id) {
        this.productService.get(id)
          .subscribe(
            data => {
              this.prod = data;
            },
            error => {
              console.log(`Product with '${id}' not found, returning to list. Error was '${error}'.`);
              this.gotoList();
            });
        this.engineService.getAll().subscribe(
          data => {
            this.engines = data;
          });
      }
    });
  }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }

  gotoList() {
    this.router.navigate(['/product-list']);
  }

  save(form: NgForm) {
    this.productService.save(form).subscribe(result => {
      this.gotoList();
    }, error => console.error(error));
  }

  remove(href) {
    this.productService.remove(href).subscribe(result => {
      this.gotoList();
    }, error => console.error(error));
  }

}

product-edit.component.html:

<form #f="ngForm" (ngSubmit)="save(f.value)">

  <div class="form-group">
    <label class="form-control-label" for="title">Title:</label>
    <input type="text" class="form-control" id="title" required [(ngModel)]="prod.title" name="title">
  </div>

  <div class="form-group">
    <label class="form-control-label" for="note">Note:</label>
    <input type="text" class="form_control" id="note" required [(ngModel)]="prod.note" name="note">
  </div>

  <div class="form-group">
    <label class="form-control-label" for="engine" #selectedValue>Engine</label>
    <select class="form-control" id="engine" name="engine" [(ngModel)]="prod.engine">
      <option [value]="null"></option>
      <option *ngFor="let engine of engines" [ngValue]="engine">{{engine.name}}</option>
    </select>
  </div>

  <button type="submit" class="btn btn-success">Submit</button>

</form>

My current problem is with the generation of the correct JSON from the Spring side for consumption on the Angular side. I believe I need to include the nested engine with the product so that the current one can be shown in the dropdown list. (It's my understanding that the [(ngModel)]="prod.engine" part should take care of this.)

In my attempt to create a bi-directional association between Product and Engine, I created a recursive loop. If I hit http://localhost:8080/products/1, Product loads Engines, which loads Products, which... wait for it... overflows the stack, and causes the browser to error out with 500. Trying to fix this problem lead me to this question: Jackson bidirectional relationship (One-to-many) not working, which is all about @JsonIdentityInfo and its associated annotations.

So I put those annotations on my model, but then I got errors about "not finding property with name 'id'". Trying to fix this problem leads to this popular question: Spring boot @ResponseBody doesn't serialize entity id. There was discussion in there about exposing ID's being the "wrong" thing to do in a REST back end, but fixing the recursive eager loading problem with @JsonIdentityInfo seems to NEED the ID's. So now I've fixed this problem, and I get the ID's in my JSON, and I don't get a recursive loop, but I still don't get the associated engine data in the JSON for a product:

{
  "id" : 1,
  "title" : "eowir",
  "note" : "noerw",
  "createDateTime" : "2018-08-22T16:10:07.349752",
  "updateDateTime" : "2018-08-22T16:10:07.349752",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/products/1"
    },
    "product" : {
      "href" : "http://localhost:8080/products/1"
    },
    "engine" : {
      "href" : "http://localhost:8080/products/1/engine"
    }
  }
}

Hitting http://localhost:8080/products/1/engine works, as does http://localhost:8080/engines/1/products. So the model works, is associated correctly, and the routing is working as intended. What is preventing me from getting the engine dumped as part of the product JSON?

Also, if I fix this problem, and get the engine stanza along with the product JSON, will that make the edit form work? Will that automatically populate the engine dropdown with the currently-associated engine?

David Krider
  • 886
  • 12
  • 27
  • 1
    I'd recommend using DTOs ([spring-hateoas Resources](https://docs.spring.io/spring-hateoas/docs/0.24.0.RELEASE/reference/html/#fundamentals.resources)), along with the spring-hateoas [ResourceAssembler](https://docs.spring.io/spring-hateoas/docs/0.24.0.RELEASE/reference/html/#fundamentals.resource-assembler). You will have more control over what the exposed data looks like, – Paul Samsotha Aug 31 '18 at 21:11
  • Well, I am well-past reluctant to add yet another layer to making this work, but this Q/A seems to be along the lines of what I need, though I can't figure out how to make it work in my application yet: https://stackoverflow.com/questions/46977974/how-can-i-return-multi-level-json-using-hibernate-jpa-in-spring-boot – David Krider Sep 01 '18 at 18:23

1 Answers1

0

So I've had discussion on Reddit about this, and people steered me away from the idea of using Spring REST. I was pointed toward https://github.com/spring-projects/spring-petclinic, which, according to its README, is a long-standing example of various projects within Spring. In all my reading to this point, I had never stumbled onto this.

From the pom.xml in that project, I see that the combination of Spring Data JPA and Spring Web provides REST services -- all by itself -- which is what I thought I needed Spring REST for. (I still seem to need the Jackson @JsonIdentityInfo annotations to prevent a stack-overflowing recursive loop on my bi-directional one-to-many relationship, but I don't seem to need the back- or managed-reference annotations.)

From the component and the form in the spring-petclinic-angular frontend sub-project, I can also, finally, see that I do have to manually set a defined, associated object instance in a dropdown select (q.v., https://github.com/spring-petclinic/spring-petclinic-angular/blob/master/src/app/pets/pet-edit/pet-edit.component.html), and then "reassemble" that selection with the parent object in the service before sending it to the update function in the backend. This was something that I'm used to Rails handling for me, and I had yet to see an example of whether Angular would somehow do that for me as well. The answer seems to be, "no."

David Krider
  • 886
  • 12
  • 27