Building Glue Stack From an Empty Folder
This covers how to create an application that manages users from a blank folder. It won't be kept quite as up to date as the Development Process Document because its a lot longer. It was interesting to go through and rewrite the application I just wrote and clarify some of the stranger parts. I think it would be really cool to show new team members how to build the core framework of the existing application from scratch but the cost might outweigh the benefit.
Create a file to write down any questions you have or anything that's unclear in this document so that we can improve it to make it easier for the next person.
Run the
setup.sh
as part of the Running Locally process. This will install some of the tools we're going to need.Make a development folder in your home folder. This is for all your development stuff.
Open terminal.
Command + Space
(Spotlight) then type terminal.It will open your home folder by default, indicated by the
~
(tilde).cd ~ mkdir development cd development
Create a folder for our app.
mkdir glue-stack cd glue-stack
Initialise the folder as a git repository. Git is a tool for
version control
which tracks changes to files.git init
Database
Connect to MySQL
We're going to download MySQL and run it using a tool called Docker Compose which uses Docker then connect to it using Sequel Pro.
Create a docker compose file.
Use vim to create a
docker-compose.yml
file.vim docker-compose.yml
Hit
i
to insert text.Paste the following:
version: '3' services: database: image: mysql/mysql-server:5.7.21 ports: - "3307:3306" environment: MYSQL_DATABASE: "glue" MYSQL_ALLOW_EMPTY_PASSWORD: "yes" MYSQL_ROOT_HOST: "%"
Finish inserting by hitting
Esc
.Save and quit using
:wq
(colon then w then q, not all at once).The standard port for MySQL is 3306 but I've chosen to map it to 3307 so it doesn't clash if you already have a MySQL database running - you can't have two applications listening to the same port.
Start the database.
docker-compose up
The MySQL database will be open after the line
Starting MySQL 5.7.21-1.1.3
. To close it hitControl + C
.Commit your changes to git from terminal.
Stage all your files (will just be our docker-compose file).
git add -A
Commit.
git commit
This will bring up a vim editor just like we did before. To write your commit message you use
i
to insert, write your commit message "added docker-compose file for database" above the commented (hashed) lines, thenesc
, then:wq
.
Download and install Sequel Pro from https://www.sequelpro.com/.
Open Sequel Pro (possibly using spotlight).
Connect to the MySQL database.
Name: localhost (localhost refers to your own computer) Host: 127.0.0.1 (the ip address of localhost - your computer) Username: root Password: (blank) Port: 3307
Click test connection.
If it succeeds click "Add to Favourites".
Click "Connect".
Select our "glue" database using the dropdown on the top left.
Create Tables
Organisation Table
Click the plus on the bottom left.
Name: organisation (its considered good practice to make sure table names aren't plurals; they're singular)
Table Encoding: UTF-8 Unicode (utf8mb4) (Unicode let's you store characters from all different languages)
Table Collation: Default (utf8mb4_general_ci)
Click Add.
That should bring up the Structure tab where you can manage the Fields of the table.
Click the plus icon in the middle of the page underneath the lone id Field to add a new field.
Field: name Type: VARCHAR (a variable length text field) Length: 255 Allow Null: false
Add another Field.
Field: active Type: BIT Length: 1 Allow Null: false
Add another Field.
Field: createdDate Type: DATETIME Length: (leave blank) Allow Null: false
Add another Field.
Field: modifiedDate Type: DATETIME Length: (leave blank) Allow Null: false
Cool you just created the organisation table! If you right click on the organisation table then go "Copy Create Table Syntax" it should look like this:
CREATE TABLE `organisation` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL DEFAULT '',
`active` bit(1) NOT NULL,
`createdDate` datetime NOT NULL,
`modifedDate` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Alternatively, you can click the console button on the top right to see the SQL statements Sequel Pro has ran then you can filter for "create" and "alter table" statements. This is useful if you've modified an existing table.
User Table
Each user belongs to a single organisation. We call this a Many-To-One relationship from a user to an organisation (or a One-To-Many relationship from an organisation to users). We model this relationship in relational databases using a foreign key. I like to think of relationships as associations as in a user is associated with an organisation and an organisation has users associated with it. The user table also has unique email addresses - no two users can have the same email.
Click the plus on the bottom left again, name the table
task
with utf8mb4 again.Add the organisation field.
The type of this column is going to match the type of the id of the organisation table. To keep things consistent all id columns are the same type.
Field: organisationId Unsigned: true (this means we only want positive numbers) Type: INT Length: 11 Allow Null: false
Add a foreign key.
A foreign key is a relational database constraint that means a particular field must match another field. In this case we're making sure the organisationId references an existing organisation.
Click the Relations tab (for the user table).
Click the plus icon.
Name: (leave blank) Column: organisationId References Table: organisation Column: id
Click Add.
Add another Field.
Field: email Type: VARCHAR Length: 255 Allow Null: false
Add a unique index to the email field.
This makes sure that two users can't have the same email address.
On the structure tab, click the plus at the very bottom of the indexes panel.
Key Type: Unique Field: Email
Click Add.
Add another Field.
Field: email Type: VARCHAR Length: 255 Allow Null: false
Add another Field.
Field: password Type: CHAR (the length of this field doesn't change) Length: 60 (the length of the password hashes - bcrypt hashes are always 60 characters) Allow Null: false
Add another Field.
Field: firstName Type: VARCHAR Length: 255 Allow Null: false
Add another Field.
Field: lastName Type: VARCHAR Length: 255 Allow Null: false
Let's add the active, createdDate, modifiedDate fields but since we did the same thing for the organisation table we can click the console icon, filter for "alter table" statements and copy the ones for those fields. Open up the query tab and paste them in there and modify them for the user table.
ALTER TABLE `user` ADD `active` BIT(1) NOT NULL;
ALTER TABLE `user` ADD `createdDate` DATETIME NOT NULL;
ALTER TABLE `user` ADD `modifiedDate` DATETIME NOT NULL;
Highlight them all then hit Command+r to run them.
That's it! Have a play with the content tab on the tables to enter data if you're keen.
API
Get the API Running
Install the Spring Boot CLI source.
brew tap pivotal/tap brew install springboot
Init a new spring boot project source.
spring init --dependencies=actuator,data-jpa,devtools,flyway,mysql,security,web --artifactId=api --groupId=org.gluestack --name=api api
Spring Actuator lets you access metrics about how the application is running
Spring Data JPA is a convenient way to access data
Spring Boot Development Tools gives us automatic reloads
Flyway keeps the database in sync with the application by managing SQL update scripts
MySQL is the Java driver for the MySQL database
Spring Security is for securing endpoints by requiring authentication
Web is for web applications and includes Tomcat and Spring MVC
You can find a full list of possible dependencies by running
spring init --list
Open the glue-stack folder.
Add some configuration to our project.
Hit Command+p then start typing
application.properties
then open it.This is where we configure our Spring Boot application.
Add the following:
# database settings spring.datasource.url=jdbc:mysql://localhost:3307/glue spring.datasource.username=root spring.datasource.password= spring.datasource.driver-class-name=com.mysql.jdbc.Driver # this overrides the default spring boot convention for tables to have underscores spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl # this makes hibernate check that our Entities match our database spring.jpa.hibernate.ddl-auto=validate # fixes a weird issue I was having with validation. It prevents jpa from validating because I wanted to do it manually so I could autowire dependencies. spring.jpa.properties.javax.persistence.validation.mode=none # logs the SQL statements hibernate sends to the database spring.jpa.show-sql=true # turns off Flyway initially spring.flyway.enabled=false
Save using
Command + s
.
Hit
Control + ~
to bring up a terminal.Run the app for the first time!
cd api mvn spring-boot:run
It should finish with
Started ApiApplication in x seconds
.You don't have to remember the command every time because you can use Control+r to search for previous commands. So you can hit Control+r then type spring to search for commands containing spring. You can then use Control+r again to search further back or Control+s search forwards.
Open Chrome and go to http://localhost:8080/.
Notice how everything is secure by default.
Commit your changes using the second menu item on the left. Just like before you can stage all or individual files and write a commit message saying "first running spring boot commit".
Configure Spring Security.
Open
ApiApplication.java
(use Command+p).In that folder create a folder called config. This will be a package in Java.
Create a file in config called
WebSecurityConfiguration.java
.package org.gluestack.api.config; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; @Configuration public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().permitAll(); } }
The @Configuration annotation means spring will use this to configure the application.
This configures Spring Security to allow all request to all endpoints.
Save using
Command + s
.Go to http://localhost:8080/actuator/health and it should say say "status: up".
This is an Actuator endpoint.
Add Database Scripts
Create a file called
V1__create.sql
atapi/src/main/resources/db/migration
(you'll need to create a db and migration folder).Go back to Sequel Pro.
Right click the organisation tables, click "Copy Create Table Syntax" then paste it into the file.
Do the same for the user table then the task table.
Its important to do it in this order because the task table depends on the user table which depends on the organisation table.
Save using
Command + s
.Delete the task table then the user table the organisation table.
Again, the order is important because of the dependencies.
Hit Control+c in the terminal where the API is running to stop it.
Open up the
application.properties
file.Remove the comment and replace the last line with.
spring.flyway.enabled=true
Save using
Command + s
.Start the API again by hitting the arrow keys up and down to find the command you previously used to start it.
Go back to Sequel Pro and hit the refresh button on the bottom of the left pain to refresh the tables.
You should see all your tables re-appaear plus a new table called
flyway_schema_history
. That table is what Flyway uses internally to check where your database is at so it can run scripts on startup so that everyone's databases is kept up to date.Commit your work saying "added database setup scripts".
Create JPA (Hibernate) Entities
So we've now defined our database tables and their relationships to one another but we still need to create Java classes to represent that data so that we can use them effectively in our Java application. These Java classes are called Entities.
JPA (Java Persistence API) is a specification for Java ORM (Object Relational Mapping). A specification is like a Java interface where you define how you interact with the API without implementing it. ORM is all about mapping relational data in our database to Object Orientated data in our application so the data is more convenient to navigate. Some people really don't like ORM and I can definitely relate but I believe it's a useful concept to know. Hibernate is our JPA implementation that makes the JPA specification work and was around before JPA so that's why some sources will talk about hibernate without mentioning JPA.
Earlier, we identified some columns were the same in all the tables; id, active, createdDate, modifiedDate. We can also see that both the user and task table have an organisationId. Let me introduce you to the first programming concept in this tutorial; DRY - don't repeat yourself. This means try not to duplicate (copy and paste) code. Some people are pretty good at identifying common code in their head but i've always found it easiest to write duplicated code first so I can easily identify the common parts then abstract
them. Abstraction means to reduce duplication.
I'm going to walk you through making the abstractions first becuase it makes this tutorial easier but I encourage you to write each of the classes in full then have a go abstracting second because I that's how I would develop outside this tutorial.
Create a file called
BaseEntity.java
atapi/src/main/java/org/gluestack/api/domain/entity
(create the domain and entity folders (packages)).package org.gluestack.api.domain.entity; import java.util.Date; import javax.persistence.Access; import javax.persistence.AccessType; import javax.persistence.Column; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.MappedSuperclass; import javax.persistence.Temporal; import javax.persistence.TemporalType; import javax.validation.constraints.NotNull; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; @MappedSuperclass public abstract class BaseEntity { @Id @Access(AccessType.PROPERTY) @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(unique = true, nullable = false) protected Integer id; @NotNull @Column(nullable = false) protected Boolean active = true; @CreationTimestamp @Temporal(TemporalType.TIMESTAMP) protected Date createdDate; @UpdateTimestamp @Temporal(TemporalType.TIMESTAMP) protected Date modifiedDate; }
Cool, what we've done is declare the id, active, createdDate and modifiedDate as
fields
and annotated them with some extra information for Hibernate. In Java the convention is to use getters and setters for each field instead of making them public so underneath the fields start writing set and get for each field and then you can use the autocomplete result. This is easier in other editors and I won't repeat this process for the rest of the tutorial.public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public Boolean getActive() { return active; } public void setActive(Boolean active) { this.active = active; } public Date getCreatedDate() { return createdDate; } public void setCreatedDate(Date createdDate) { this.createdDate = createdDate; } public Date getModifiedDate() { return modifiedDate; } public void setModifiedDate(Date modifiedDate) { this.modifiedDate = modifiedDate; }
Create a
BaseOrganisedEntity.java
in the same folder and generate the getter and setter for the organisation field. Don't generate the getter and setter for the superclass (BaseEntity) fields. Don't worry about compilation errors until after we've created all our entities because they all refer to each other.package org.gluestack.api.domain.entity; import javax.persistence.FetchType; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.MappedSuperclass; @MappedSuperclass public abstract class BaseOrganisedEntity extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "organisationId", nullable = false) protected Organisation organisation; }
Create
Organisation.java
. This is largely what you would call aPOJO
in Java - Plain Old Java Object. APOJO
is Java class that just has fields and getters and setters. We've added some annotations but I still largely consider this aPOJO
.package org.gluestack.api.domain.entity; import java.util.ArrayList; import java.util.List; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.OneToMany; import javax.persistence.Table; import javax.validation.Valid; import org.hibernate.annotations.Cascade; import org.hibernate.annotations.CascadeType; @Entity @Table(name = "organisation") public class Organisation extends BaseEntity { @Column(nullable = false, length = 255) private String name; @OneToMany(mappedBy = "organisation") private List<Task> tasks = new ArrayList<>(); @Valid @Cascade(CascadeType.ALL) @OneToMany(mappedBy = "organisation") private List<User> users = new ArrayList<>(); public Organisation() { } }
Create
User.java
. This one is a little bit more involved because it's the implementation for our user for Spring Security.package org.gluestack.api.domain.entity; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.ArrayList; import java.util.Collection; import java.util.List; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.OneToMany; import javax.persistence.Table; import javax.validation.constraints.NotBlank; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @Entity @Table(name = "user") public class User extends BaseOrganisedEntity implements UserDetails { @NotBlank @Column(nullable = false, unique = true, length = 255) private String email; @NotBlank @Column(nullable = false, length = 255) private String firstName; @Column(nullable = false, length = 255) private String lastName; @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) @NotBlank @Column(nullable = false, length = 60, columnDefinition = "CHAR(60)") private String password; @OneToMany(mappedBy = "user") private List<Task> tasks = new ArrayList<>(); public User() { } public String getEmail() { return this.email; } public void setEmail(String email) { this.email = email; } public String getFirstName() { return this.firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return this.lastName; } public void setLastName(String lastName) { this.lastName = lastName; } @Override public String getPassword() { return this.password; } public void setPassword(String password) { this.password = password; } public List<Task> getTasks() { return tasks; } public void setTasks(List<Task> tasks) { this.tasks = tasks; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getUsername() { return email; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return active; } }
Start the application. Hibernate will validate that your data classes match your database and will fail to start if they don't.
mvn spring-boot:run
Commit your work; "created entities".
Create the Repository Layer
The repository layer in Spring is a layer dedicated for communicating with data sources.
To do this, we'll need to add some dependencies
to our project. Dependencies are dependencies on libraries
. A Library is a separate, reusable piece of code that does something and makes your life easier and associated with the concept of abstraction
which means to reduce complexity. A lot of people write Java Web Applications so its only natural that we deal with the same problems so libraries are an attempt to solve some of those common problems. In Java you used to have to download a compiled library but this is hard to maintain and leads to large project sizes so we use what's called dependency management where you simply specify the library and the version that you want and that will be used to download that library for you. In Java you can use Maven to manage dependencies and it stores the version information in a pom.xml
file.
Add Querydsl JPA as a dependency. Querydsl JPA is a Java library for typesafe JPA queries that also works really well with another one of our cool libraries, Spring-Data-JPA, which is powering our repository layer.
Open the pom.xml file (using command + p) and add the following line to the properties element.
<querydsl.version>4.2.1</querydsl.version>
Add the following dependency below Flyway.
<dependency> <groupId>com.querydsl</groupId> <artifactId>querydsl-jpa</artifactId> <version>${querydsl.version}</version> </dependency>
Add the following plugin to the plugins element below the spring-boot-maven-plugin.
<plugin> <groupId>com.mysema.maven</groupId> <artifactId>apt-maven-plugin</artifactId> <version>1.1.3</version> <executions> <execution> <goals> <goal>process</goal> </goals> <configuration> <outputDirectory>target/generated-sources/java</outputDirectory> <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor> </configuration> </execution> </executions> <dependencies> <dependency> <groupId>com.querydsl</groupId> <artifactId>querydsl-apt</artifactId> <version>${querydsl.version}</version> </dependency> </dependencies> </plugin>
Add this plugin so VSCode finds the classes that Querydsl generates.
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> <executions> <execution> <id>add-source</id> <phase>generate-sources</phase> <goals> <goal>add-source</goal> </goals> <configuration> <sources> <source>${project.build.directory}/generated-sources/java/</source> </sources> </configuration> </execution> </executions> </plugin>
Create the
BaseRepository.java
file atapi/src/main/java/org/gluestack/api/repository
.package org.gluestack.api.repository; import org.springframework.data.querydsl.QuerydslPredicateExecutor; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.NoRepositoryBean; @NoRepositoryBean public interface BaseRepository<T> extends CrudRepository<T, Integer>, QuerydslPredicateExecutor<T> { }
Create the
OrganisationRepository.java
.package org.gluestack.api.repository; import org.gluestack.api.domain.entity.Organisation; public interface OrganisationRepository extends BaseRepository<Organisation> { }
Create the
UserRepository.java
. The findOneByEmail method will allow us to find a User in the database by their email and the EntityGraph annotation will load their Organisation in the same query. This will be useful for authentication. This is where Spring Data JPA is pretty cool, its an interface that automagically implements itself!package org.gluestack.api.repository; import org.gluestack.api.domain.entity.User; import org.springframework.data.jpa.repository.EntityGraph; public interface UserRepository extends BaseRepository<User> { @EntityGraph(attributePaths = { "organisation" }) User findOneByEmail(String email); }
Restart the application to make sure its all correct.
Commit your work; "created repository layer".
Create the Service Layer
The service layer is where most of your API logic lives. Some people choose to split up the service layer further where they see fit but I prefer Services call other Services as required instead of creating mandatory layers.
Create the
BaseService.java
file atapi/src/main/java/org/gluestack/api/service
. Its a lot of custom code that I've developed for this project so just copy and paste it from this repo; BaseService.java. You may have to update the package and some of the imports so it compiles.Create the
OrganisationService.java
file in the same folder.This demonstrates the general structure of the services:
There's a method that returns a Querydsl Predicate which is a typesafe database criteria that has defines what entities can be returned from the database. You can see that this will only match the organisation of the current user and this is repeated for the other services as well.
The create method for an organisation also needs to create a user which this service is not responsible for so it uses a method on the UserService.
package org.gluestack.api.service; import org.gluestack.api.domain.entity.Organisation; import org.gluestack.api.domain.entity.QOrganisation; import org.gluestack.api.domain.entity.User; import org.gluestack.api.repository.OrganisationRepository; import com.querydsl.core.types.Predicate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service public class OrganisationService extends BaseService<Organisation> { @Autowired private OrganisationRepository organisationRepository; @Autowired private UserService userService; @Override public Predicate getReadPermissionPredicate(User principalUser) { return QOrganisation.organisation.id.eq(principalUser.getOrganisation().getId()); } public Organisation create(Organisation organisation) { User user = organisation.getUsers().get(0); user.setOrganisation(organisation); userService.preparePassword(user, null); return organisationRepository.save(organisation); } }
Create the
UserService.java
.In this Service it overrides one of the save lifecycle methods to implement some extra functionality; hashing a user's plaintext password. The hashing functionality is provided by Spring Security and is important for keeping passwords safe in the database because it is a one way hash, it can't be decrypted. The API validates user's passwords by applying the hash to the password supplied during authentication and is checked against the hash in the database. The only way to 'decrypt' the hash is to generate hashes which should take a long time. That's why new hashing algorithms are created to stay ahead of the speed improvements of computers.
Java has a convention of
Mapping
which might help here but I don't like the pattern much so i'm trying to avoid it and I think this code is okay.package org.gluestack.api.service; import org.gluestack.api.domain.entity.QUser; import org.gluestack.api.domain.entity.User; import com.querydsl.core.types.Predicate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class UserService extends BaseService<User> { @Autowired private PasswordEncoder passwordEncoder; @Override public Predicate getReadPermissionPredicate(User principalUser) { return QUser.user.organisation.id.eq(principalUser.getOrganisation().getId()); } @Override public void prepareSaveData(User principalUser, User newEntity, User oldEntity) { preparePassword(newEntity, oldEntity); super.prepareSaveData(principalUser, newEntity, oldEntity); } public void preparePassword(User newUser, User oldUser) { if (oldUser == null || !oldUser.getPassword().equals(newUser.getPassword())) { newUser.setPassword(passwordEncoder.encode(newUser.getPassword())); } } }
As always, start the appliation to make sure it works then commit your changes.
Create the Controller layer
The controller layer is what connects our application to the internet. It defines what URLs the application listens to and what Java objects we're expecting to receive or send. Spring MVC will convert the Java objects to and from JSON which is convenient for the frontend.
Create the
BaseController.java
atapi/src/main/java/org/gluestack/api/controller
. Again, like the theBaseService
its custom code so copy and paste it; BaseController.java.Create the
OrganisationController.java
. This controller is a bit unique because it needs to handle new signups which means it works without a logged in user.package org.gluestack.api.controller; import org.gluestack.api.domain.entity.Organisation; import org.gluestack.api.service.OrganisationService; import javax.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("api/organisations") public class OrganisationController extends BaseController<Organisation> { @Autowired private OrganisationService organisationService; @Override @RequestMapping(method = RequestMethod.POST) public Organisation create(Authentication authentication, @RequestBody @Valid Organisation entity) { Organisation organisation = organisationService.create(entity); organisation.setUsers(null); // this is a bit of a hack to fix some serialisation issues. return organisation; } }
Create the
UserController.java
. This is what our typical controller method looks like, I would have liked to move the findAll method to the BaseController but currently its not possible.package org.gluestack.api.controller; import org.gluestack.api.domain.entity.User; import org.gluestack.api.service.UserService; import com.querydsl.core.types.Predicate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.querydsl.binding.QuerydslPredicate; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("api/users") public class UserController extends BaseController<User> { @Autowired private UserService userService; @RequestMapping(method = RequestMethod.GET) public Page<User> findAll(Authentication authentication, @QuerydslPredicate Predicate predicate, Pageable pageRequest) { User principalUser = (User) authentication.getPrincipal(); return userService.findAll(principalUser, predicate, pageRequest); } }
Create the
AuthenticationController.java
. This is what the frontend will call to login and if authentication is successful it will return the details of the current user.package org.gluestack.api.controller; import org.gluestack.api.domain.entity.User; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("api/authenticate") public class AuthenticationController { @RequestMapping(method = RequestMethod.GET) public User authenticate(Authentication authentication) { return (User) authentication.getPrincipal(); } }
Run the application and commit your work.
Final Configuration
Add the dependency for Jackson Hibernate Module inside the dependencies element in your
pom.xml
file.In Java web applications there is the concept of mapping where you convert your Entities into other Java objects which the API would return. Instead of doing that I prefer to use my Entities for the output but Jackson, the library that spring uses to convert Java objects to and from JSON (serialisation and deserialisation), would try and serialise every relation of each entitity (and their relations and so on and so on), causing an infinite loop. This library prevents this by making Jackson only serialise the entities that have been loaded from the database. That means if you want to return more data all you have to do is load more data from the database, which you would have to do anyway if you mapped entities.
<dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-hibernate5</artifactId> </dependency>
Create
WebMvcConfig.java
inside the config folder.This will enable the Jackson Hibernate Module and the Spring Data Web Support which converts URL parameters into a Querydsl predicate to support filtering.
package org.gluestack.api.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.web.config.EnableSpringDataWebSupport; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module; import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module.Feature; @Configuration @EnableSpringDataWebSupport public class WebMvcConfig implements WebMvcConfigurer { @Bean public Jackson2ObjectMapperBuilder jacksonBuilder() { Jackson2ObjectMapperBuilder b = new Jackson2ObjectMapperBuilder(); Hibernate5Module hm = new Hibernate5Module(); hm.enable(Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS); hm.disable(Feature.USE_TRANSIENT_ANNOTATION); b.modulesToInstall(hm); return b; } }
Create the
UserDetailsService.java
. This will be used to configure Spring Security and will be used as part of authentication.package org.gluestack.api.config; import org.gluestack.api.repository.UserRepository; import org.gluestack.api.domain.entity.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class SecurityUserService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { User user = userRepository.findOneByEmail(email); if (user == null) { throw new UsernameNotFoundException("Username " + email + " not found"); } return user; } }
Open up the
WebSecurityConfiguration.java
file. Make it look like this:package org.gluestack.api.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired private SecurityUserService securityUserService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(securityUserService).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); http.authorizeRequests().antMatchers(HttpMethod.POST, "/api/organisations").permitAll(); http.authorizeRequests().antMatchers("/api/**").fullyAuthenticated(); http.httpBasic(); http.csrf().disable(); } }
Start the application and commit your work.
Tests
I'm not the best at tests but I think I like this approach.
Add the dependency on Testcontainers in the
pom.xml
.Testcontainers allow us use a real MySQL database for our tests and its loaded using docker so you have to have that installed but luckily that's part of our setup scripts.
<dependency> <groupId>org.testcontainers</groupId> <artifactId>mysql</artifactId> <version>1.6.0</version> <scope>test</scope> </dependency>
Create an
application-test.properties
atapi/src/main/resources/
.This will specify to connect to the testcontainers database during testing. You don't need to replace anything in this code snippet.
# database settings spring.datasource.url=jdbc:tc:mysql:5.7.21://somehostname:someport/databasename spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver
Delete the
ApiApplication.java
file fromapi/src/test/java/org/gluestack/api
.In that same directory, create the
BaseTest.java
.My current testing strategy is to have a decent amount of test data available so that you don't have to setup a lot of data for new tests. This might not scale in the long run so I may have to break it up a bit but I think the concept of making it really easy to setup data or not having to do it at all is a good.
The other important thing here is the Transactional annotation. That each test will run in a separate database transaction and will be rolled-back after the test completes. This means the database will be in the same state before each tests regardless of which tests ran before it, making our tests independent.
package org.gluestack.api; import com.fasterxml.jackson.databind.ObjectMapper; import javax.transaction.Transactional; import org.gluestack.api.domain.entity.Organisation; import org.gluestack.api.domain.entity.Task; import org.gluestack.api.domain.entity.User; import org.gluestack.api.repository.OrganisationRepository; import org.gluestack.api.repository.TaskRepository; import org.gluestack.api.repository.UserRepository; import org.junit.Before; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; @RunWith(SpringRunner.class) @AutoConfigureMockMvc @SpringBootTest @Transactional @TestPropertySource(locations = "classpath:application-test.properties") public abstract class BaseTest { @Autowired protected MockMvc mvc; @Autowired protected ObjectMapper objectMapper; @Autowired private TestOrganisationService testOrganisationService; }
In that same folder create
TestOrganisation.java
. This is the object to pass around test data.package org.gluestack.api; import org.gluestack.api.domain.entity.Organisation; import org.gluestack.api.domain.entity.Task; import org.gluestack.api.domain.entity.User; public class TestOrganisation { public Organisation organisation; public User actingUser; public User otherUser; }
Create
TestOrganisationService.java
. This creates the test data.package org.gluestack.api; import org.gluestack.api.domain.entity.Organisation; import org.gluestack.api.domain.entity.Task; import org.gluestack.api.domain.entity.User; import org.gluestack.api.repository.OrganisationRepository; import org.gluestack.api.repository.TaskRepository; import org.gluestack.api.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class TestOrganisationService { @Autowired private OrganisationRepository organisationRepository; @Autowired private UserRepository userRepository; @Autowired private PasswordEncoder passwordEncoder; public TestOrganisation createTestOrganisiation() { TestOrganisation testOrganisation = new TestOrganisation(); Organisation organisation = new Organisation(); organisation.setName("organisation"); organisationRepository.save(organisation); testOrganisation.organisation = organisation; User actingUser = new User(); actingUser.setEmail(organisation.getId() + "actingUser"); actingUser.setPassword(passwordEncoder.encode("password")); actingUser.setFirstName("Acting"); actingUser.setLastName("User"); actingUser.setOrganisation(organisation); userRepository.save(actingUser); testOrganisation.actingUser = actingUser; User otherUser = new User(); otherUser.setEmail(organisation.getId() + "otherUser"); otherUser.setPassword(passwordEncoder.encode("password")); otherUser.setFirstName("Other"); otherUser.setLastName("User"); otherUser.setOrganisation(organisation); userRepository.save(otherUser); testOrganisation.otherUser = otherUser; return testOrganisation; } }
Open
BaseTest.java
and add the following to the bottom of the class. This will setup two organisations and their data so that we can reference it for our tests.protected TestOrganisation testOrganisation; protected TestOrganisation otherTestOrganisation; @Before public void setup() { testOrganisation = testOrganisationService.createTestOrganisiation(); otherTestOrganisation = testOrganisationService.createTestOrganisiation(); }
Create a
POJO
for thePageResponse
the list views return. I'm not 100% sure why we have to do this but its not terribly hard. Copy it from https://github.com/cadbox1/glue-stack/blob/master/api/src/test/java/com/api/PageResponse.javaCreate
CreateTest.java
atapi/src/test/java/org/gluestack/api/organisation
package org.gluestack.api.organisation; import org.assertj.core.util.Arrays; import org.gluestack.api.BaseTest; import org.gluestack.api.domain.entity.Organisation; import org.gluestack.api.domain.entity.User; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import java.util.ArrayList; import java.util.List; public class CreateTest extends BaseTest { @Test public void createTest() throws Exception { User user = new User(); user.setFirstName("Create"); user.setLastName("Test"); user.setEmail("CreateTest"); user.setPassword("CreateTest"); Organisation organisation = new Organisation(); organisation.setName("Create Test Organisation"); List<User> users = new ArrayList<>(); users.add(user); organisation.setUsers(users); mvc.perform(post("/api/organisations").contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(organisation))).andReturn(); mvc.perform(get("/api/authenticate").with(httpBasic(user.getUsername(), user.getPassword()))).andReturn(); } }
Create
FindAllTest.java
atapi/src/test/java/org/gluestack/api/user
package org.gluestack.api.user; import org.gluestack.api.BaseTest; import org.gluestack.api.PageResponse; import org.gluestack.api.domain.entity.Task; import org.gluestack.api.domain.entity.User; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.junit.Assert.assertEquals; import org.junit.Test; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; import org.springframework.test.web.servlet.MvcResult; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; public class FindAllTest extends BaseTest { @Test public void findAllUsersTest() throws Exception { MvcResult mvcResult = mvc .perform(get("/api/users").with(httpBasic(testOrganisation.actingUser.getUsername(), "password"))) .andReturn(); PageResponse<User> page = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), objectMapper.getTypeFactory().constructParametricType(PageResponse.class, User.class)); int resultSize = 2; assertEquals(resultSize, page.getNumberOfElements()); assertEquals(resultSize, page.getTotalElements()); assertThat(page.getContent(), hasSize(resultSize)); User result = page.getContent().get(0); assertThat(result.getFirstName(), equalTo(testOrganisation.actingUser.getFirstName())); assertThat(result.getLastName(), equalTo(testOrganisation.actingUser.getLastName())); assertThat(result.getEmail(), equalTo(testOrganisation.actingUser.getEmail())); } }
At the command line, inside the api folder, run this command to run all the tests.
mvn test
Commit your work.
UI
Create React App
Open a new terminal in VSCode and run this command.
yarn global add create-react-app
This will install
create-react-app
globally.create-react-app
is by far the most popular way to get aReact
project started.Create a new project by running the command then the name of the project.
create-react-app ui
Follow the commands it displays in the terminal.
cd ui yarn start
That should open a working react app in your browser, how easy was that!
Add this line before the dependencies.
This will route any unkown urls to our API.
"proxy": "http://localhost:8080",
Stop the front end development server using
Control + c
.Run yarn. This will produce a new lockfile.
yarn
Yarn is an alternative package manager to npm that uses a lockfile that records the exact version of each dependency that is installed. The lockfile differs from that
package.json
which stores the ranges of versions that are to be installed or updated to. e.g do you want to jump major versions or just minor versions. The lockfile means more consistent results across different environments.Commit your work.
create a
.env
file atui/
.This will allow us to import the folders inside the src folder absolutely instead of relatively which is more robust when moving stuff around. Its always a good idea to link any relative issues in code that help you come to a solution.
// https://github.com/facebookincubator/create-react-app/issues/741#issuecomment-278945308 NODE_PATH=src/
Create a
.prettierrc.yaml
file.This will specify how we want the prettier to format our code, including the prettier VSCode plugin we installed.
useTabs: true trailingComma: es5
Create a
tsconfig.json
file.This will let VSCode understand our project. Tt will make the autocompletes, called Intellisense in VSCode, a lot more helpful.
{ "compilerOptions": { "allowJs": true, "baseUrl": "src/" } }
Commit your changes.
Add Libraries
We're going to add some Libraries to our project mainly in the form of dependencies. Just like in Java we're going to have a file that holds our library names and versions so that we can download them all instead of putting them in our project. When we run yarn add
it will download the library from NPM
and add it to our package.json
.
Open a terminal window and cd into the ui directory.
React Router (and friends). This is for Routing which is about mapping the url in the address bar to different pages in our app.
yarn add react-router-dom yarn add history yarn add query-string
Axios is a small library that makes netwrok requests just that much easier.
yarn add axios
Material UI Beta. Material UI is the most popular material design library for
React
. This library is going to heavily influence the design of our app using the popular Material Design specifications. Their previous version had some pretty big limitations once you got into it but they've learned a lot and delivered a fantastic beta version. Being a Beta they tend toMove Fast and Break Things
(and that's a good thing) so we're going to use the exact version I know works.yarn add material-ui@1.0.0-beta.23 yarn add material-ui-icons@1.0.0-beta.17
Bootstrap. Okay, we definitely shouldn't need this but I know the utilities fairly well so it helped me get the ui working quickly but I will definitely be removing this eventually.
yarn add bootstrap
React Component Queries allows us to detect the sizes of components so that we can keep our app responsive and working nicely on mobile devices.
yarn add react-sizeme react-component-queries
Okay this next step is a bit of a cop out. Its the re-usable code I've developed while creating the frontend. I really should be explaining each peice but I think i'll come back and do that later. Sorry!
Copy the common folder to
ui/src/
.Start the frontend server. This will automatically open the app in a browser window.
yarn start
Setup React Router and Material UI at the root component. Open
App.js
.import React from "react"; import { Router } from "react-router-dom"; import { MuiThemeProvider, createMuiTheme } from "material-ui/styles"; import { history } from "common/history"; import "./App.css"; import "bootstrap/dist/css/bootstrap.css"; import { Main } from "main"; const theme = createMuiTheme(); const App = () => ( <Router history={history}> <MuiThemeProvider theme={theme}> <p>App</p> </MuiThemeProvider> </Router> ); export default App;
The browser window should automatically refresh and display the word "App".
Delete the
logo.svg
.Open
App.css
.html, body, #root { height: 100%; width: 100%; } .h-100vh { height: 100vh; }
Commit your work.
API Requests
Create a folder called
api
atui/src/
.Create the
organisation.js
file.import axios from "axios"; const path = "organisations"; export function save(body) { if (body.id) { return axios.patch(`${path}/${body.id}`, body); } else { return axios.post(path, body); } }
Create the
user.js
file.import axios from "axios"; const path = "users"; export function findAll({ page, size, sort } = {}) { return axios.get(path, { params: { page, size, sort } }); } export function findOne(id) { return axios.get(`${path}/${id}`); } export function save(body) { if (body.id) { return axios.patch(`${path}/${body.id}`, body); } else { return axios.post(path, body); } }
Create the
task.js
file.import axios from "axios"; const path = "tasks"; export function findAll({ userId, statusId, page, size, sort } = {}) { return axios.get(path, { params: { "user.id": userId, statusId, page, size, sort }, }); } export function findOne(id) { return axios.get(`${path}/${id}`); } export function patch(id, body) { return axios.patch(`${path}/${id}`, body); } export function save(body) { if (body.id) { return patch(body.id, body); } else { return axios.post(path, body); } } export const TaskStatus = { TODO: 0, IN_PROGRESS: 1, DONE: 2, };
Create the
authentication.js
file.import axios from "axios"; import { setCredentials } from "common/axiosConfig"; const credentialsKey = "credentials"; export function authenticate({ username, password } = {}) { if (username == null) { const credentials = getCredentials(); if (!credentials) { return Promise.reject(); } username = credentials.username; password = credentials.password; } return axios .get("authenticate", { auth: { username, password } }) .then(result => { setCredentials(username, password); saveCredentials(username, password); return result; }); } export function signOut() { setCredentials(); localStorage.removeItem(credentialsKey); } function getCredentials() { const credentialsJSON = localStorage.getItem(credentialsKey); if (!credentialsJSON) { return; } return JSON.parse(credentialsJSON); } function saveCredentials(username, password) { localStorage.setItem(credentialsKey, JSON.stringify({ username, password })); }
Commit your work.
Authentication Component
Create a folder called
main
atui/src/
.Create the
index.js
file atui/src/main
.import React, { Component } from "react"; import { connect } from "common/connector"; import { authenticate, signOut } from "api/authentication"; class Main extends Component { componentDidMount() { this.authenticate(); } signOut = () => { signOut(); this.authenticate(); }; authenticate() { const { authenticate } = this.props; authenticate.call().catch(() => authenticate.reset()); // swallow any errors } render() { return <p>Main</p> } } Main = connect({ authenticate: { promise: authenticate, }, })(Main); export { Main };
Open
App.js
.Delete the
<p>App</p>
line and start typing the word<Main
. Then hit enter on the first autocomplete result that says "import main". This will automatically add the import for theMain
component.Close the
Main
component tag with/>
so it says<Main />
.The browser window should now show "Main".
Commit your work.
Login Component
Create the
index.js
file atui/src/main/unauthenticated/login
.import React, { Component } from "react"; import { Link, withRouter } from "react-router-dom"; import Card, { CardActions, CardContent } from "material-ui/Card"; import Typography from "material-ui/Typography"; import TextField from "common/components/TextField"; import { CircularProgress } from "material-ui/Progress"; import Button from "material-ui/Button";
class Login extends Component { constructor(props) { super(props); this.state = { email: "", password: "" }; }
handleInput = evt => { this.setState({text [evt.target.name]: evt.target.value, }); const { authenticate } = this.props; if (authenticate.rejected) { authenticate.reset(); } }; handleSubmit = evt => { evt.preventDefault(); const { email, password } = this.state; const { authenticate } = this.props; authenticate.call({ username: email, password }); }; render() { const { email, password } = this.state; const { authenticate } = this.props; const invalidLogin = authenticate.rejected && authenticate.reason && authenticate.reason.response && authenticate.reason.response.status === 401; return ( <div className="d-flex align-items-md-center justify-content-center" style={{ height: "100vh" }} > <div style={{ maxHeight: "100%", maxWidth: "350px" }} className="w-100"> <Card> <form onSubmit={this.handleSubmit}> <CardContent> <Typography type="headline" component="h2"> Login </Typography> <Typography type="body1"> <Link to="/signup">or Signup Here</Link> </Typography> <TextField label="Email" name="email" value={email} error={authenticate.rejected} onChange={this.handleInput} required /> <TextField label="Password" type="password" name="password" value={password} error={authenticate.rejected} helperText={ (invalidLogin && "Invalid Username or Password") || (authenticate.reason && authenticate.reason.message) } onChange={this.handleInput} required /> </CardContent> <CardActions> <Button raised color="primary" type="submit"> {authenticate.pending ? ( <CircularProgress size={15} /> ) : ( "Login" )} </Button> </CardActions> </form> </Card> </div> </div> ); }
}
Login = withRouter(Login);
export { Login };
```
Create the
index.js
atui/src/main/unauthenticated
.import React, { Component } from "react"; import { Route, Switch } from "react-router-dom"; import { Login } from "./login"; class Unauthenticated extends Component { render() { return <Login />; } } export { Unauthenticated };
Open the
main/index.js
by hitting Command + p then start typingmain
then type/
.Add this between the start of the render function and the return.
const { authenticate } = this.props; if (!authenticate.fulfilled) { return <Unauthenticated authenticate={authenticate} />; }
So it looks like this.
render() { const { authenticate } = this.props; if (!authenticate.fulfilled) { return <Unauthenticated authenticate={authenticate} />; } return <p>Main</p>; }
Put yor cursor at the end of
Unauthenticated
word then hit Control + space to bring up the autocomplete. Select the option to import the Unauthenticated component, it should be the second option.The browser should now be showing a login form.
Signup Component
Notice how our Signup Here link doesn't work. Let's fix that.
Create the
index.js
file atui/src/main/unauthenticated/signup
.import React, { Component } from "react"; import { Link, withRouter } from "react-router-dom"; import { connect } from "common/connector"; import Card, { CardActions, CardContent } from "material-ui/Card"; import Typography from "material-ui/Typography"; import { CircularProgress } from "material-ui/Progress"; import TextField from "common/components/TextField"; import Button from "material-ui/Button"; import { save } from "api/organisation"; class Signup extends Component { constructor(props) { super(props); this.state = { name: "", firstName: "", lastName: "", email: "", password: "", }; } handleFormInput = evt => { this.setState({ [evt.target.name]: evt.target.value, }); const { save } = this.props; if (save.rejected) { this.props.save.reset(); } }; handleSubmit = evt => { evt.preventDefault(); const { save } = this.props; const { name, firstName, lastName, email, password } = this.state; const body = { name, users: [{ firstName, lastName, email, password }] }; save .call(body) .then(() => this.props.authenticate.call({ username: email, password })) .then(() => this.props.history.push("/")); }; render() { const { save } = this.props; const { name, firstName, lastName, email, password } = this.state; console.log(JSON.stringify(save)); const emailNotUnique = save.rejected && save.reason.response && save.reason.response.data.errors && Array.isArray(save.reason.response.data.errors) && save.reason.response.data.errors.some( error => error.code === "UniqueEmailConstraint" ); return ( <div className="d-flex align-items-md-center justify-content-center" style={{ height: "100vh" }} > <div style={{ maxHeight: "100%", maxWidth: "350px" }} className="w-100"> <Card> <form onSubmit={this.handleSubmit}> <CardContent> <Typography type="headline" component="h2"> Signup </Typography> <Typography type="body1"> <Link to="/">or Login Here</Link> </Typography> <TextField name="name" value={name} onChange={this.handleFormInput} label="Organisation" required /> <TextField name="firstName" value={firstName} onChange={this.handleFormInput} label="First Name" required /> <TextField name="lastName" value={lastName} onChange={this.handleFormInput} label="Last Name" required /> <TextField name="email" value={email} onChange={this.handleFormInput} label="Email" error={emailNotUnique} helperText={emailNotUnique && "That email is already taken"} required /> <TextField name="password" value={password} onChange={this.handleFormInput} label="Password" type="password" required /> </CardContent> <CardActions> <Button raised color="primary" type="submit"> {save.pending ? <CircularProgress size={15} /> : "Signup"} </Button> </CardActions> </form> </Card> </div> </div> ); } } Signup = connect({ save: { promise: save, }, })(withRouter(Signup)); export { Signup };
Open
unauthenticated/index.js
usingCommand + p
.Before the
<Route>
, create a route for the signup component.<Route path="/signup" render={props => <Signup {...props} {...this.props} />} />
Again, use autocomplete to import the signup component. I'm not going to say this anymore you can just do it for new stuff.
The signup link should now work as well as the login here link on the signup page.
First Signup
Start the API and Database if they are not already running.
In separate tabs in VSCode.
Database.
docker-compose up
API.
cd api mvn spring-boot:run
Fill in the signup form and click signup. You should now see the word "Main" which means you are logged in.
Sidebar
Create
index.js
atui/src/main/authenticated/sidebar
.import React, { Component } from "react"; import { Link, withRouter } from "react-router-dom"; import Drawer from "./drawer"; import List, { ListItem, ListItemText } from "material-ui/List"; import Avatar from "material-ui/Avatar"; import Collapse from "material-ui/transitions/Collapse"; import ExpandLess from "material-ui-icons/ExpandLess"; import ExpandMore from "material-ui-icons/ExpandMore"; import Divider from "material-ui/Divider"; class Sidebar extends Component { constructor(props) { super(props); this.state = { userMenuOpen: false, }; } handleClick = evt => { this.setState({ userMenuOpen: !this.state.userMenuOpen }); }; render() { const { authenticate, showSideBar, signOut, temporaryDock } = this.props; const { userMenuOpen } = this.state; const user = authenticate.value.data; return ( <Drawer open={showSideBar} type={temporaryDock ? "temporary" : "persistent"} style={{ width: showSideBar ? "256px" : "0px" }} onRequestClose={this.props.toggleSideBar} > <List style={{ padding: 0 }}> <ListItem button onClick={this.handleClick}> <Avatar style={{ textTransform: "uppercase" }}> {user.firstName[0]} </Avatar> <ListItemText primary={`${user.firstName} ${user.lastName}`} /> {userMenuOpen ? <ExpandLess /> : <ExpandMore />} </ListItem> <Collapse component="li" in={userMenuOpen} unmountOnExit> <List disablePadding> <ListItem button onClick={signOut}> <ListItemText inset primary="Sign Out" /> </ListItem> </List> </Collapse> <Divider /> <Link to="/users"> <ListItem button> <ListItemText primary="Users" /> </ListItem> </Link> </List> </Drawer> ); } } Sidebar = withRouter(Sidebar); export { Sidebar };
Create a
drawer.js
file in the same folder.import React from "react"; import Drawer from "material-ui/Drawer"; import { withStyles } from "material-ui/styles"; const styles = { paper: { position: "static", width: "256px", }, }; function OverridesClasses({ classes, children, ...props }) { return ( <Drawer classes={{ paper: classes.paper, }} {...props} > {children} </Drawer> ); } export default withStyles(styles)(OverridesClasses);
Create
index.js
atui/src/main/authenticated
.import React, { Component } from "react"; import { Redirect, Route, Switch } from "react-router-dom"; import componentQueries from "react-component-queries"; import { Sidebar } from "./sidebar"; class Authenticated extends Component { constructor(props) { super(props); this.state = { showSideBar: !props.temporaryDock, }; } componentWillReceiveProps(nextProps) { if (nextProps.temporaryDock !== this.props.temporaryDock) { this.setState({ showSideBar: !nextProps.temporaryDock }); } } toggleSideBar = evt => { const { showSideBar } = this.state; this.setState({ showSideBar: !showSideBar }); }; render() { const { signOut, temporaryDock } = this.props; const { showSideBar } = this.state; return ( <div style={{ display: "flex" }}> <Sidebar temporaryDock={temporaryDock} signOut={signOut} showSideBar={showSideBar} toggleSideBar={this.toggleSideBar} {...this.props} /> <button onClick={this.toggleSideBar}>Toggle Sidebar</button> </div> ); } } Authenticated = componentQueries({ queries: [ ({ width }) => ({ temporaryDock: width < 800, }), ], config: { pure: false }, })(Authenticated); export { Authenticated };
Open
main/index.js
and replacereturn "Main"
in the render function with:return <Authenticated signOut={this.signOut} authenticate={authenticate} />;
You should now be able to logout, toggle the sidebar and see how it behaves differently on smaller screens. None of the links change anything but we'll fix that soon.
Commit your changes.
Users
Create the
list.js
file atui/src/main/authenticated/user/
.import React, { Component } from "react"; import { Link } from "react-router-dom"; import Table, { TableBody, TableHead, TableRow, TableFooter, } from "material-ui/Table"; import { TableCell } from "common/components/tableCell"; import Radio from "material-ui/Radio"; import { parseURL } from "common/parseURL"; import { TableSortLabel } from "common/components/tableSortLabel"; import { TablePagination } from "common/components/tablePagination"; import { findAll } from "api/user"; export class List extends Component { render() { const { findAll, listURL, onSelect, selected } = this.props; return ( <Table> <TableHead> <TableRow> {onSelect && <TableCell padding="checkbox" />} <TableCell> <TableSortLabel findAll={findAll} property="firstName"> Name </TableSortLabel> </TableCell> <TableCell> <TableSortLabel findAll={findAll} property="email"> Email </TableSortLabel> </TableCell> </TableRow> </TableHead> <TableBody> {findAll.rejected && ( <TableRow> <TableCell colSpan={2}> {findAll.reason ? ( <div> <p>{findAll.reason.error}</p> <p>{findAll.reason.exception}</p> <p>{findAll.reason.message}</p> </div> ) : ( <p>Error</p> )} </TableCell> </TableRow> )} {findAll.value && findAll.value.data.content.map(row => { const name = `${row.firstName} ${row.lastName}`; return ( <TableRow key={row.id} onClick={onSelect && onSelect.bind(null, row)} hover={onSelect} style={{ cursor: onSelect ? "pointer" : "default" }} selected={selected && selected.includes(row.id)} > {onSelect && ( <TableCell padding="checkbox"> <Radio checked={selected && selected.includes(row.id)} /> </TableCell> )} <TableCell> {listURL ? ( <Link to={`${listURL}/${row.id}`}>{name}</Link> ) : ( name )} </TableCell> <TableCell>{row.email}</TableCell> </TableRow> ); })} </TableBody> {findAll.fulfilled && ( <TableFooter> <TableRow> <TablePagination findAll={findAll} /> </TableRow> </TableFooter> )} </Table> ); } } export const connectConfig = { findAll: { params: props => parseURL(props), promise: findAll, }, };
Create the
form.js
file in the same folder.import React, { Component } from "react"; import { Link } from "react-router-dom"; import AppBar from "material-ui/AppBar"; import IconButton from "material-ui/IconButton"; import Close from "material-ui-icons/Close"; import TextField from "common/components/TextField"; import Button from "material-ui/Button"; import Paper from "material-ui/Paper"; import Toolbar from "material-ui/Toolbar"; import Typography from "material-ui/Typography"; import { CircularProgress } from "material-ui/Progress"; import { findOne, save } from "api/user"; import { connect } from "common/connector"; class Form extends Component { constructor(props) { super(props); this.state = this.defaultState; if (props.match.params.id) { // eslint-disable-next-line this.state.password = undefined; } } defaultState = { id: null, firstName: "", lastName: "", email: "", password: "", }; componentDidMount() { if (this.props.findOne) { this.props.findOne.subscribe(value => { const { id, firstName, lastName, email } = value.data; this.setState({ id, firstName, lastName, email }); }); } } handleSubmit = evt => { evt.preventDefault(); const { save, refreshList, history } = this.props; save.call(this.state).then(result => { if (refreshList) { refreshList(); } if (!this.state.id) { history.push(`/users/${result.data.id}`); } }); }; handleFormInput = evt => { this.setState({ [evt.target.name]: evt.target.value }); }; handleResetPassword = evt => { this.setState({ password: "" }); }; render() { const { id, firstName, lastName, email, password } = this.state; const { className } = this.props; return ( <Paper className={className} elevation={1}> <AppBar position="static"> <Toolbar> <Typography type="title" color="inherit" className="mr-auto"> {id ? `${firstName} ${lastName}` : "Create"} </Typography> <Link to={`/users`}> <IconButton color="contrast"> <Close /> </IconButton> </Link> </Toolbar> </AppBar> <form onSubmit={this.handleSubmit} className="container-fluid"> <TextField name="firstName" value={firstName} onChange={this.handleFormInput} label="First Name" required /> <TextField name="lastName" value={lastName} onChange={this.handleFormInput} label="Last Name" required /> <TextField name="email" value={email} onChange={this.handleFormInput} label="Email" required /> {password !== undefined ? ( <TextField name="password" type="password" value={password} onChange={this.handleFormInput} label="Password" required /> ) : ( <Button type="button" onClick={this.handleResetPassword}> Reset Password </Button> )} <Button raised className="d-block" type="submit" color="primary"> {save.pending ? ( <CircularProgress size={15} /> ) : id ? ( "Save" ) : ( "Create" )} </Button> </form> </Paper> ); } } export default Form; export const Create = connect({ save: { promise: save } })(Form); export const Edit = connect({ findOne: { params: props => props.match.params.id, promise: findOne, }, })(Create);
Create the
index.js
file.import React, { Component } from "react"; import { Link, Route, Switch } from "react-router-dom"; import componentQueries from "react-component-queries"; import AppBar from "material-ui/AppBar"; import Toolbar from "material-ui/Toolbar"; import Typography from "material-ui/Typography"; import IconButton from "material-ui/IconButton"; import MenuIcon from "material-ui-icons/Menu"; import Add from "material-ui-icons/Add"; import Refresh from "material-ui-icons/Refresh"; import { CircularProgress } from "material-ui/Progress"; import { connect } from "common/connector"; import { urlStateHolder } from "common/stateHolder"; import { connectConfig, List } from "./list"; import { Create, Edit } from "./form"; class User extends Component { render() { const { match, findAll, singleView, toggleSideBar } = this.props; return ( <div className="row no-gutters w-100"> <Route path={`${match.path}`} exact={singleView} render={props => ( <div className="col h-100vh"> <AppBar position="static"> <Toolbar> <IconButton onClick={toggleSideBar} color="contrast" aria-label="Menu" > <MenuIcon /> </IconButton> <Typography color="inherit" type="title" className="mr-auto"> Users </Typography> <IconButton color="contrast" onClick={findAll.refresh}> {findAll.pending ? ( <span> <CircularProgress color="inherit" size={14} /> </span> ) : ( <Refresh /> )} </IconButton> <Link to={`${match.path}/create`}> <IconButton color="contrast"> <Add /> </IconButton> </Link> </Toolbar> </AppBar> <List listURL={match.path} findAll={findAll} /> </div> )} /> <Switch> <Route path={`${match.path}/create`} render={props => ( <Create {...props} className="col h-100vh" refreshList={singleView ? undefined : findAll.refresh} /> )} /> <Route path={`${match.path}/:id`} render={props => ( <Edit {...props} className="col h-100vh" refreshList={singleView ? undefined : findAll.refresh} /> )} /> </Switch> </div> ); } } User = componentQueries({ queries: [ ({ width }) => ({ singleView: width < 1000, }), ], config: { pure: false }, })(urlStateHolder(connect(connectConfig)(User))); export { User };
Replace the button in the
authenticated/index.js
file with these routes.<Switch> <Route path="/me" render={props => ( <Me {...props} {...this.props} toggleSideBar={this.toggleSideBar} /> )} /> <Route path="/tasks" render={props => ( <Task {...props} {...this.props} toggleSideBar={this.toggleSideBar} /> )} /> <Route path="/users" render={props => ( <User {...props} {...this.props} toggleSideBar={this.toggleSideBar} /> )} /> <Route exactly path="/" render={() => <Redirect to="/users" />} /> </Switch>
Add the
react-router
imports and use the auto import for the User componentimport { Switch, Route, Redirect } from "react-router-dom"
Commit your work.
Last updated