Local development implies setting up your local environment with all dependencies and configurations needed for the service to work.
Which dependencies and configurations are necessary, are explained in the sections below.
Before you start the service in your local environment, you must install the necessary dependencies.
The first one is Node.js. It is used for running the backend side of Gradient Image Processing.
Instructions for installing Node.js
After installing node.js, the next one is npm. It's a node package manager.
Once you have npm installed you can run the following instruction, both to install and upgrade Yarn:
npm install --global yarn
To install all necessary dependencies needed for the Gradient Image Processing project, you need to identify the directory "image-processing-service-backend" and execute the following command in that directory:
yarn install
We continue setting up our local environment by creating the empty ".env" file (or copying .env.template content) at the root of the project. We use the .env file to store environment-specific configurations or settings for a project. In this section, it is explained how to set variables with values needed for database connection.
For this example, "PostgresSQL" database is used. Inside the ".env" file, you should have content like in the example below:
TYPEORM_CONNECTION = "postgres"
TYPEORM_HOST = "localhost"
TYPEORM_USERNAME = "test"
TYPEORM_PASSWORD = "test"
TYPEORM_DATABASE = "test"
TYPEORM_PORT = 5432
TYPEORM_SYNCHRONIZE = false
TYPEORM_LOGGING = true
TYPEORM_ENTITIES = "entity/*.js"
TYPEORM_MIGRATIONS = "migration/*.js"
For instructions on how to fill the .env file for the different databases, please check Connection Options.
To bring the initially empty database (without any tables structure) to the state required by the Gradient Image Processing service, you need to run the command:
yarn run migration
The last step required to start the server is to run the command:
yarn run start:dev
When there is a need to deploy the solution to production, it is good to have a clear path what exactly is needed for the service to work. In this section, docker deployment is described. It includes Dockerfile with installation steps for Ubuntu 20.04. and bash script for running Gradient Image Processing.
Only dependency needed for this system to work is docker. Installation steps can be read here.
In the root of the project, Dockerfile with the following content is provided:
FROM ubuntu:20.04
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update
RUN apt-get install -y vim curl nodejs npm
RUN npm install n -g
RUN n 16.13
RUN npm install yarn -g
CMD ["/bin/bash", "/var/www/configure-project-on-dev.sh"]
Using the preferred cli interface, you should navigate to project root and execute:
docker build -t image-processing .
As the result of this operation, a docker image with the name "image-processing" is built.
Finally, to run the service (and the built docker image), the following command is suggested:
docker run --detach \
--hostname image-processing-hostname \
--publish 1000:3000 \
--name image-processing \
--restart always \
--volume /project-root-path/:/var/www \
--shm-size 256m \
image-processing
All parameters should be configured as needed. In this example, Gradient Image Processing is running on port 1000, as long as /project-root-path/ is properly configured to point to the Gradient Image Processing root project.
Before jumping to the sections below, you need to go to a login page of Gradient Image Processing by opening the url http(s)://<host>:<port>. If everything is configured properly, you should see a login screen similar to the one in the following screenshot.
One of the migrations that were executed from previous steps included the insertion of Admin. So, the first step on the login page is to log in as Admin using the credentials:
email: admin@gradient.ba;
password: admin
After success, it is advised to immediately change the Admin password by using the "Change Password" functionality. You can find the "Change Password" link in the navigation bar on the right side. Click on it and change the password using provided interface.
On the navigation bar, there is the 'Clients' link. Clicking on it, you will be presented with the Clients List Page. So, the next action you need to perform is to create a Client. In order to do that, you need to click on the "Add new" button. If everything is performed correctly, you should be presented with an interface similar to the one in the following screenshot:
Fill in the wanted client's data similar to the example below.
fullName: 'Test Client',
email: 'test@test.ba',
password: 'test',
status: 'ACTIVE' or 'INACTIVE'.
NOTE: Only 'ACTIVE' Clients are previewed on the list of clients.
After the successful creation of the Client, you can log out, by clicking on the "Logout" link on the navbar.
The next step is to log in as the Client. So, on the login page, you need to fill in the credentials of the previously created Client. After successful login, and clicking on the navbar "Client Applications" link, the Client App List Page is presented. The next action you need to perform is to create a Client App, by clicking on the "Add new" button.
Fill in the wanted Client App's data similar to the example below:
name: 'Test Client App',
status: 'ACTIVE' or 'INACTIVE'.
Insertion of configurations is the final step in configuring Gradient Image Processing for a single Client App.
NOTE: Frontend interface for managing configurations is not in its final implementation state. To achieve configuration insert, it is currently necessary to insert configurations directly into the database.
PostgreSQL instructions needed to be executed are presented below. The use case presented here assumes the requirement for our Client App that for every uploaded image the following versions of it need to be created:
-- Execute every set of queries separately, since results of previous ones are needed in the next ones
-- First set! Insert information about wanted image types (WEBP and JPEG, 90% quality).
insert into image_type_configuration_entity ("imageType", "qualityPercentage") VALUES ('WEBP', 90);
insert into image_type_configuration_entity ("imageType", "qualityPercentage") VALUES ('JPEG', 90);
-- Second set! Insert information about configurations (to connect clientAppId and image types).
-- NOTE: ClientAppId is the "id" attribute of the Client App in the database, not the "AppID".
insert into configuration_entity ("name", "clientAppId", "imageTypeConfigurationId") VALUES ('Webp configuration', 3, '7a9ac778-1336-4d02-b2f0-aba6f3533f67');
insert into configuration_entity ("name", "clientAppId", "imageTypeConfigurationId") VALUES ('Jpeg configuration', 3, 'b53a9d16-77ff-436a-aeaf-6d41df84eb49');
-- Third set! Insert information about wanted configuration sizes, for every available configuration from the second set.
insert into image_size_configuration_entity (width, height, "cropMethod", "configurationId") VALUES (1900, null, null, 6);
insert into image_size_configuration_entity (width, height, "cropMethod", "configurationId") VALUES (450, null, null, 6);
insert into image_size_configuration_entity (width, height, "cropMethod", "configurationId") VALUES (960, null, null, 6);
insert into image_size_configuration_entity (width, height, "cropMethod", "configurationId") VALUES (100, null, null, 6);
insert into image_size_configuration_entity (width, height, "cropMethod", "configurationId") VALUES (1900, null, null, 7);
insert into image_size_configuration_entity (width, height, "cropMethod", "configurationId") VALUES (450, null, null, 7);
insert into image_size_configuration_entity (width, height, "cropMethod", "configurationId") VALUES (960, null, null, 7);
insert into image_size_configuration_entity (width, height, "cropMethod", "configurationId") VALUES (100, null, null, 7);
For more information on which configurations are supported and how database can be filled, one can check ER Diagram of Gradient Image Processing database.
After Local Development or Docker Deployment are performed, and Client App with its credentials and configurations is stored into the database (Insert Configurations) system is ready to be used by Consumer Service.
Consumer service performs the following operations against Gradient Image Processing service:
Code samples are added in the following sections in different programming languages, separated by upper operations.
Installed npm dependencies and predefined constants for this example are:
import fs from 'fs';
import axios from "axios";
import FormData from 'form-data';
import WebSocket from "ws";
// Constants that are needed for Gradient Image Processing usage
const API_HOST = 'localhost';
const API_PATH = ['http://', API_HOST, ':3000/api'].join('');
const WS_PATH = ['ws://', API_HOST, ':8080'].join('');
const appID = 'u61v5mrq5wSOaAXxbYKE';
const secretID = 'GS50RJ1PI2HeZNEd0usf';
Authorization of Client Application to get token:
const loginPath = [API_PATH, 'login/client-app'].join('/');
const loginResponse = await axios.post(loginPath, {
appID,
secretID
});
const token = loginResponse.data.key;
Upload image (loading using fs from local directory):
const token = loginResponse.data.key;
const imageUploadPath = [API_PATH, 'images/upload-image'].join('/');
const formData = new FormData();
formData.append('imageContent', fs.createReadStream('../images/original-1.jpg'));
formData.append('imageAccessibility', 'PUBLIC');
const imageUploadResponse = await axios.post(imageUploadPath, formData, {
headers: {
...formData.getHeaders(),
token,
}
});
const {progressEventBusName, sourceImageId, resultImages} = imageUploadResponse.data;
Access response data about result images
const {progressEventBusName, sourceImageId, resultImages} = imageUploadResponse.data;
// Store id of source/original image to be able to delete it.
console.log(sourceImageId);
// Retrieve result images info
resultImages.forEach((resultImage) => {
console.log(resultImage);
});
Receive data through web sockets about processing progress:
const socketClient = new WebSocket(WS_PATH);
socketClient.on('open', () => {
socketClient.on('message', (data) => {
console.log("Received upload information:");
console.log(data.toString());
});
socketClient.send(JSON.stringify({
type: 'LISTEN',
data: {
token,
clientAppID: appID,
progressEventBusNames: [progressEventBusName],
}
}));
});
Make request to delete all images (source and result images):
const imageDeletePath = [API_PATH, 'images', sourceImageId].join('/');
const deleteResponse = await axios.delete(imageDeletePath, {
headers: {
token,
}
});
console.log("Delete response status:");
console.log(deleteResponse.status);
Installed composer dependencies and predefined constants for this example are:
use GuzzleHttp\Client;
use WebSocket\ConnectionException;
$API_HOST = 'localhost';
$API_PATH = implode('', ['http://', $API_HOST, ':3000/api']);
$WS_PATH = implode('', ['ws://', $API_HOST, ':8080']);
$appID = 'u61v5mrq5wSOaAXxbYKE';
$secretID = 'GS50RJ1PI2HeZNEd0usf';
Authorization of Client Application to get token:
$client = new Client();
$loginPath = implode('/', [$API_PATH, 'login/client-app']);
$loginResponse = $client->post($loginPath, [
'json' => [
'appID' => $appID,
'secretID' => $secretID,
]
]);
$tokenData = json_decode($loginResponse->getBody(), true);
$token = $tokenData['key'];
Upload image (loading from local directory):
$imageData = file_get_contents(__DIR__ . '/../../images/original-1.jpg');
$imageUploadPath = implode("/", [$API_PATH, 'images/upload-image']);
$uploadResultResponse = $client->post($imageUploadPath, [
'headers' => [
'token' => $token
],
'multipart' => [
[
'name' => 'imageContent',
'contents' => $imageData,
'filename' => 'image-filename.jpg',
],
[
'name' => 'imageAccessibility',
'contents' => 'PUBLIC',
]
]
]);
$uploadData = json_decode($uploadResultResponse->getBody(), true);
$sourceImageId = $uploadData['sourceImageId'];
$progressEventBusName = $uploadData['progressEventBusName'];
$resultImages = $uploadData['resultImages'];
Access response data about result images
$sourceImageId = $uploadData['sourceImageId'];
$progressEventBusName = $uploadData['progressEventBusName'];
$resultImages = $uploadData['resultImages'];
// Store id of source/original image to be able to delete it.
echo $sourceImageId;
// Retrieve result images info
foreach ($resultImages as $resultImage) {
var_dump($resultImage);
}
Receive data through web sockets about processing progress:
$client = new WebSocket\Client($WS_PATH);
$client->text(json_encode([
'type' => 'LISTEN',
'data' => [
'token' => $token,
'clientAppID' => $appID,
'progressEventBusNames' => [$progressEventBusName],
]
]));
while(1) {
try {
echo $client->receive();
} catch (ConnectionException $e) {}
}
Make request to delete all images (source and result images):
$imageDeletePath = implode("/", [$API_PATH, 'images', $sourceImageId]);
$deleteResponse = $client->delete($imageDeletePath, [
'headers' => [
'token' => $token
]
]);
echo "Delete response status:";
echo $deleteResponse->getStatusCode() . PHP_EOL;
Installed pip dependencies and predefined constants for this example are:
import asyncio
import json
import requests
import websockets
API_HOST = 'localhost'
API_PATH = ''.join(['http://', API_HOST, ':3000/api'])
WS_PATH = ''.join(['ws://', API_HOST, ':8080'])
app_id = 'u61v5mrq5wSOaAXxbYKE'
secret_id = 'GS50RJ1PI2HeZNEd0usf'
Authorization of Client Application to get token:
login_path = '/'.join([API_PATH, 'login/client-app'])
login_response = requests.post(login_path, json={'appID': app_id, 'secretID': secret_id})
token_data = login_response.json()
token = token_data['key']
Upload image (loading from local directory):
image_data = open('../images/original-1.jpg', 'rb')
image_upload_path = '/'.join([API_PATH, 'images/upload-image'])
image_upload_response = requests.post(image_upload_path,
files={
'imageContent': ('original-1.jpg', image_data),
},
data={
'imageAccessibility': 'PUBLIC'
},
headers={
'token': token
})
upload_data = image_upload_response.json()
source_image_id = upload_data['sourceImageId']
progress_event_bus_name = upload_data['progressEventBusName']
result_images = upload_data['resultImages']
Access response data about result images:
source_image_id = upload_data['sourceImageId']
progress_event_bus_name = upload_data['progressEventBusName']
result_images = upload_data['resultImages']
print(source_image_id)
for result_image in result_images:
print(result_image)
Receive data through web sockets about processing progress:
async def on_message(message):
print(message)
async def connect_web_socket():
async with websockets.connect(WS_PATH) as websocket:
listen_message = json.dumps({
'type': 'LISTEN',
'data': {
'token': token,
'clientAppID': app_id,
'progressEventBusNames': [progress_event_bus_name],
}
})
await websocket.send(listen_message)
while True:
asyncio.create_task(on_message(await websocket.recv()))
asyncio.run(connect_web_socket())
Make request to delete all images (source and result images):
image_delete_path = '/'.join([API_PATH, 'images', source_image_id])
delete_response = requests.delete(image_delete_path,
headers={
'token': token
})
print('Delete response status:')
print(delete_response.status_code)
Installed gradle dependencies and predefined constants for this example are:
package com.gradient.imageprocessing.javashowcase;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.WebSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
public class App {
private static final String API_HOST = "localhost";
private static final String API_PATH = "http://" + API_HOST + ":3000/api";
private static final String WS_PATH = "ws://" + API_HOST + ":8080";
private static final String APP_ID = "u61v5mrq5wSOaAXxbYKE";
private static final String SECRET_ID = "GS50RJ1PI2HeZNEd0usf";
Authorization of Client Application to get token:
HttpClient client = HttpClient.newHttpClient();
HttpRequest loginRequest = HttpRequest.newBuilder()
.uri(new URI("/" + API_PATH + "/login/client-app"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(new JSONObject()
.append("appID", APP_ID)
.append("secretID", SECRET_ID)
.toString()))
.build();
HttpResponse loginResponse = client.send(loginRequest, HttpResponse.BodyHandlers.ofString());
JSONObject loginResponseBody = new JSONObject(loginResponse.body());
String token = loginResponseBody.getString("key");
Upload image (loading from local directory):
String imageUploadPath = String.join("/", new String[]{API_PATH, "images/upload-image"});
File file = new File("../../images/original-1.jpg");
Map<Object, Object> imageUploadRequestBody = new HashMap<>();
imageUploadRequestBody.put("imageContent", file.toPath());
imageUploadRequestBody.put("imageAccessibility", "PUBLIC");
String boundary = new BigInteger(256, new Random()).toString();
HttpRequest imageUploadRequest = HttpRequest.newBuilder()
.uri(new URI(imageUploadPath))
.header("Content-Type", "multipart/form-data;boundary=" + boundary)
.header("token", token)
.POST(ofMimeMultipartData(imageUploadRequestBody, boundary))
.build();
HttpResponse<String> imageUploadResponse = client.send(imageUploadRequest, HttpResponse.BodyHandlers.ofString());
JSONObject uploadImageResponseBody = new JSONObject(imageUploadResponse.body());
String sourceImageId = uploadImageResponseBody.getString("sourceImageId");
String progressEventBusName = uploadImageResponseBody.getString("progressEventBusName");
JSONArray resultImages = uploadImageResponseBody.getJSONArray("resultImages");
// Implementation ofMimeMultipartData
// Reference: https://github.com/ralscha/blog2019/blob/master/java11httpclient/client/src/main/java/ch/rasc/httpclient/File.java#L69-L91
public static HttpRequest.BodyPublisher ofMimeMultipartData(Map<Object, Object> data,
String boundary) throws IOException {
var byteArrays = new ArrayList<byte[]>();
byte[] separator = ("--" + boundary + "\r\nContent-Disposition: form-data; name=")
.getBytes(StandardCharsets.UTF_8);
for (Map.Entry<Object, Object> entry : data.entrySet()) {
byteArrays.add(separator);
if (entry.getValue() instanceof Path) {
var path = (Path) entry.getValue();
String mimeType = Files.probeContentType(path);
byteArrays.add(("\"" + entry.getKey() + "\"; filename=\"" + path.getFileName()
+ "\"\r\nContent-Type: " + mimeType + "\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
byteArrays.add(Files.readAllBytes(path));
byteArrays.add("\r\n".getBytes(StandardCharsets.UTF_8));
}
else {
byteArrays.add(("\"" + entry.getKey() + "\"\r\n\r\n" + entry.getValue() + "\r\n")
.getBytes(StandardCharsets.UTF_8));
}
}
byteArrays.add(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
return HttpRequest.BodyPublishers.ofByteArrays(byteArrays);
}
Access response data about result images:
String sourceImageId = uploadImageResponseBody.getString("sourceImageId");
String progressEventBusName = uploadImageResponseBody.getString("progressEventBusName");
JSONArray resultImages = uploadImageResponseBody.getJSONArray("resultImages");
System.out.println(sourceImageId);
for (int i = 0; i < resultImages.length(); i++) {
JSONObject resultImage = resultImages.getJSONObject(i);
System.out.println(resultImage);
}
Receive data through web sockets about processing progress:
JSONArray progressEventBusNames = new JSONArray();
progressEventBusNames.put(progressEventBusName);
JSONObject listenData = new JSONObject();
listenData.put("token", token);
listenData.put("clientAppID", APP_ID);
listenData.put("progressEventBusNames", progressEventBusNames);
JSONObject listenRequest = new JSONObject();
listenRequest.put("type", "LISTEN");
listenRequest.put("data", listenData);
CountDownLatch latch = new CountDownLatch(20); // Set how many messages to receive before closing connection.
WebSocket ws = HttpClient
.newHttpClient()
.newWebSocketBuilder()
.buildAsync(URI.create(WS_PATH), new WebSocketClient(latch))
.join();
ws.sendText(listenRequest.toString(), true);
latch.await();
// Reference: https://stackoverflow.com/questions/55380813/require-assistance-with-simple-pure-java-11-websocket-client-example
private static class WebSocketClient implements WebSocket.Listener {
private final CountDownLatch latch;
public WebSocketClient(CountDownLatch latch) { this.latch = latch; }
@Override
public void onOpen(WebSocket webSocket) {
System.out.println("onOpen using subprotocol " + webSocket.getSubprotocol());
WebSocket.Listener.super.onOpen(webSocket);
}
@Override
public CompletionStage> onText(WebSocket webSocket, CharSequence data, boolean last) {
System.out.println(data);
latch.countDown();
return WebSocket.Listener.super.onText(webSocket, data, last);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
System.out.println("Error occurred: " + webSocket.toString());
WebSocket.Listener.super.onError(webSocket, error);
}
}
Make request to delete all images (source and result images):
String imageDeletePath = String.join("/", new String[]{API_PATH, "images", sourceImageId});
HttpRequest imageDeleteRequest = HttpRequest.newBuilder()
.uri(new URI(imageDeletePath))
.header("Content-Type", "application/json")
.header("token", token)
.DELETE()
.build();
HttpResponse<String> imageDeleteResponse = client.send(imageDeleteRequest, HttpResponse.BodyHandlers.ofString());
System.out.println("Delete response status:");
System.out.println(imageDeleteResponse.statusCode());