🥇 First commit

This commit is contained in:
2021-06-17 12:04:45 -07:00
commit dd3033177b
20 changed files with 10629 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
docker-compose.env
docker-compose.yml
Dockerfile
node_modules
test
.vscode

35
.editorconfig Normal file
View File

@@ -0,0 +1,35 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = tab
indent_size = 4
space_after_anon_function = true
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
indent_style = space
indent_size = 4
[{package,bower}.json]
indent_style = space
indent_size = 2
[*.{yml,yaml}]
trim_trailing_whitespace = false
indent_style = space
indent_size = 2
[*.js]
quote_type = "double"

143
.eslintrc.js Normal file
View File

@@ -0,0 +1,143 @@
module.exports = {
env: {
browser: true,
es6: true,
node: true
},
ignorePatterns: [ "test/*"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
sourceType: "module"
},
plugins: ["prefer-arrow", "import", "@typescript-eslint"],
rules: {
"@typescript-eslint/adjacent-overload-signatures": "error",
"@typescript-eslint/array-type": "error",
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/class-name-casing": "off",
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
accessibility: "explicit"
}
],
"@typescript-eslint/indent": [
"off",
4,
{
FunctionDeclaration: {
parameters: "first"
},
FunctionExpression: {
parameters: "first"
}
}
],
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/member-delimiter-style": [
"error",
{
multiline: {
delimiter: "semi",
requireLast: true
},
singleline: {
delimiter: "semi",
requireLast: false
}
}
],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/quotes": [
"error",
"double",
{
avoidEscape: true
}
],
"@typescript-eslint/semi": ["error", "always"],
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unified-signatures": "error",
"arrow-body-style": "error",
"arrow-parens": ["error", "as-needed"],
camelcase: "error",
"capitalized-comments": "error",
"comma-dangle": ["error", "always-multiline"],
complexity: "off",
"constructor-super": "error",
curly: "error",
"dot-notation": "error",
"eol-last": "error",
eqeqeq: ["error", "smart"],
"guard-for-in": "error",
"id-blacklist": ["error", "any", "Number", "number", "String", "string", "Boolean", "boolean", "Undefined", "undefined"],
"id-match": "error",
"import/order": "error",
"max-classes-per-file": ["error", 1],
"max-len": [
"error",
{
"ignoreUrls": true ,
code: 160
}
],
"new-parens": "error",
"no-bitwise": "error",
"no-caller": "error",
"no-cond-assign": "error",
"no-console": "off",
"no-debugger": "error",
"no-empty": "error",
"no-eval": "error",
"no-fallthrough": "off",
"no-invalid-this": "off",
"no-multiple-empty-lines": "error",
"no-new-wrappers": "error",
"no-shadow": [
"error",
{
hoist: "all"
}
],
"no-throw-literal": "error",
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-underscore-dangle": "error",
"no-unsafe-finally": "error",
"no-unused-expressions": "error",
"no-unused-labels": "error",
"no-var": "error",
"object-shorthand": "error",
"one-var": ["error", "never"],
"prefer-arrow/prefer-arrow-functions": "error",
"prefer-const": "error",
"quote-props": ["error", "consistent-as-needed"],
radix: "error",
"space-before-function-paren": [
"error",
{
anonymous: "never",
asyncArrow: "always",
named: "never"
}
],
"spaced-comment": "error",
"use-isnan": "error",
"valid-typeof": "off",
}
};

68
.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next
# JetBrains IDE
.idea
# Don't track transpiled files
dist/
.vscode

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:lts-alpine
# Working directory
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm ci --silent
# Copy source
COPY . .
# Build and cleanup
ENV NODE_ENV=production
RUN npm run build \
&& npm prune
# Start server
CMD ["npm", "start"]

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
[![Moleculer](https://badgen.net/badge/Powered%20by/Moleculer/0e83cd)](https://moleculer.services)
# comicvine-service
This is a [Moleculer](https://moleculer.services/)-based microservices project. Generated with the [Moleculer CLI](https://moleculer.services/docs/0.14/moleculer-cli.html).
## Usage
Start the project with `npm run dev` command.
After starting, open the http://localhost:3000/ URL in your browser.
On the welcome page you can test the generated services via API Gateway and check the nodes & services.
In the terminal, try the following commands:
- `nodes` - List all connected nodes.
- `actions` - List all registered service actions.
- `call greeter.hello` - Call the `greeter.hello` action.
- `call greeter.welcome --name John` - Call the `greeter.welcome` action with the `name` parameter.
## Services
- **api**: API Gateway services
- **greeter**: Sample service with `hello` and `welcome` actions.
## Useful links
* Moleculer website: https://moleculer.services/
* Moleculer Documentation: https://moleculer.services/docs/0.14/
## NPM scripts
- `npm run dev`: Start development mode (load all services locally with hot-reload & REPL)
- `npm run start`: Start production mode (set `SERVICES` env variable to load certain services)
- `npm run cli`: Start a CLI and connect to production. Don't forget to set production namespace with `--ns` argument in script
- `npm run lint`: Run ESLint
- `npm run ci`: Run continuous test mode with watching
- `npm test`: Run tests & generate coverage report
- `npm run dc:up`: Start the stack with Docker Compose
- `npm run dc:down`: Stop the stack with Docker Compose

9
docker-compose.env Normal file
View File

@@ -0,0 +1,9 @@
NAMESPACE=
LOGGER=true
LOGLEVEL=info
SERVICEDIR=dist/services
TRANSPORTER=nats://nats:4222
CACHER=Memory

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
version: "3.3"
services:
api:
build:
context: .
image: comicvine-service
env_file: docker-compose.env
environment:
SERVICES: api
PORT: 3000
depends_on:
- nats
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-gw.rule=PathPrefix(`/`)"
- "traefik.http.services.api-gw.loadbalancer.server.port=3000"
networks:
- internal
greeter:
build:
context: .
image: comicvine-service
env_file: docker-compose.env
environment:
SERVICES: greeter
depends_on:
- nats
networks:
- internal
nats:
image: nats:2
networks:
- internal
traefik:
image: traefik:v2.1
command:
- "--api.insecure=true" # Don't do that in production!
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
ports:
- 3000:80
- 3001:8080
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- internal
- default
networks:
internal:
volumes:
data:

164
k8s.yaml Normal file
View File

@@ -0,0 +1,164 @@
#########################################################
# Common Environment variables ConfigMap
#########################################################
apiVersion: v1
kind: ConfigMap
metadata:
name: common-env
data:
NAMESPACE: ""
LOGLEVEL: info
SERVICEDIR: dist/services
TRANSPORTER: nats://nats:4222
CACHER: Memory
---
#########################################################
# Service for Moleculer API Gateway service
#########################################################
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api
ports:
- port: 3000
targetPort: 3000
---
#########################################################
# Ingress for Moleculer API Gateway
#########################################################
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: ingress
spec:
rules:
- host: comicvine-service.127.0.0.1.nip.io
http:
paths:
- path: /
backend:
serviceName: api
servicePort: 3000
---
#########################################################
# API Gateway service
#########################################################
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
selector:
matchLabels:
app: api
replicas: 2
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: comicvine-service
envFrom:
- configMapRef:
name: common-env
env:
- name: SERVICES
value: api
- name: PORT
value: "3000"
ports:
- containerPort: 3000
---
#########################################################
# Greeter service
#########################################################
apiVersion: apps/v1
kind: Deployment
metadata:
name: greeter
spec:
selector:
matchLabels:
app: greeter
replicas: 2
template:
metadata:
labels:
app: greeter
spec:
containers:
- name: greeter
image: comicvine-service
envFrom:
- configMapRef:
name: common-env
env:
- name: SERVICES
value: greeter
---
#########################################################
# NATS transporter service
#########################################################
apiVersion: v1
kind: Service
metadata:
name: nats
spec:
selector:
app: nats
ports:
- port: 4222
name: nats
targetPort: 4222
---
#########################################################
# NATS transporter
#########################################################
apiVersion: apps/v1
kind: Deployment
metadata:
name: nats
spec:
selector:
matchLabels:
app: nats
replicas: 1
strategy:
type: Recreate
template:
metadata:
labels:
app: nats
spec:
containers:
- name: nats
image: nats
ports:
- containerPort: 4222
name: nats

208
moleculer.config.ts Normal file
View File

@@ -0,0 +1,208 @@
"use strict";
import {BrokerOptions, Errors, MetricRegistry, ServiceBroker} from "moleculer";
/**
* Moleculer ServiceBroker configuration file
*
* More info about options:
* https://moleculer.services/docs/0.14/configuration.html
*
*
* Overwriting options in production:
* ================================
* You can overwrite any option with environment variables.
* For example to overwrite the "logLevel" value, use `LOGLEVEL=warn` env var.
* To overwrite a nested parameter, e.g. retryPolicy.retries, use `RETRYPOLICY_RETRIES=10` env var.
*
* To overwrite brokers deeply nested default options, which are not presented in "moleculer.config.js",
* use the `MOL_` prefix and double underscore `__` for nested properties in .env file.
* For example, to set the cacher prefix to `MYCACHE`, you should declare an env var as `MOL_CACHER__OPTIONS__PREFIX=mycache`.
* It will set this:
* {
* cacher: {
* options: {
* prefix: "mycache"
* }
* }
* }
*/
const brokerConfig: BrokerOptions = {
// Namespace of nodes to segment your nodes on the same network.
namespace: "",
// Unique node identifier. Must be unique in a namespace.
nodeID: null,
// Custom metadata store. Store here what you want. Accessing: `this.broker.metadata`
metadata: {},
// Enable/disable logging or use custom logger. More info: https://moleculer.services/docs/0.14/logging.html
// Available logger types: "Console", "File", "Pino", "Winston", "Bunyan", "debug", "Log4js", "Datadog"
logger: {
type: "Console",
options: {
// Using colors on the output
colors: true,
// Print module names with different colors (like docker-compose for containers)
moduleColors: false,
// Line formatter. It can be "json", "short", "simple", "full", a `Function` or a template string like "{timestamp} {level} {nodeID}/{mod}: {msg}"
formatter: "full",
// Custom object printer. If not defined, it uses the `util.inspect` method.
objectPrinter: null,
// Auto-padding the module name in order to messages begin at the same column.
autoPadding: false,
},
},
// Default log level for built-in console logger. It can be overwritten in logger options above.
// Available values: trace, debug, info, warn, error, fatal
logLevel: "info",
// Define transporter.
// More info: https://moleculer.services/docs/0.14/networking.html
// Note: During the development, you don't need to define it because all services will be loaded locally.
// In production you can set it via `TRANSPORTER=nats://localhost:4222` environment variable.
transporter: null, // "NATS"
// Define a cacher.
// More info: https://moleculer.services/docs/0.14/caching.html
cacher: "Memory",
// Define a serializer.
// Available values: "JSON", "Avro", "ProtoBuf", "MsgPack", "Notepack", "Thrift".
// More info: https://moleculer.services/docs/0.14/networking.html#Serialization
serializer: "JSON",
// Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0
requestTimeout: 10 * 1000,
// Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry
retryPolicy: {
// Enable feature
enabled: false,
// Count of retries
retries: 5,
// First delay in milliseconds.
delay: 100,
// Maximum delay in milliseconds.
maxDelay: 1000,
// Backoff factor for delay. 2 means exponential backoff.
factor: 2,
// A function to check failed requests.
check: (err: Errors.MoleculerError) => err && !!err.retryable,
},
// Limit of calling level. If it reaches the limit, broker will throw an MaxCallLevelError error. (Infinite loop protection)
maxCallLevel: 100,
// Number of seconds to send heartbeat packet to other nodes.
heartbeatInterval: 10,
// Number of seconds to wait before setting node to unavailable status.
heartbeatTimeout: 30,
// Cloning the params of context if enabled. High performance impact, use it with caution!
contextParamsCloning: false,
// Tracking requests and waiting for running requests before shuting down. More info: https://moleculer.services/docs/0.14/context.html#Context-tracking
tracking: {
// Enable feature
enabled: false,
// Number of milliseconds to wait before shuting down the process.
shutdownTimeout: 5000,
},
// Disable built-in request & emit balancer. (Transporter must support it, as well.). More info: https://moleculer.services/docs/0.14/networking.html#Disabled-balancer
disableBalancer: false,
// Settings of Service Registry. More info: https://moleculer.services/docs/0.14/registry.html
registry: {
// Define balancing strategy. More info: https://moleculer.services/docs/0.14/balancing.html
// Available values: "RoundRobin", "Random", "CpuUsage", "Latency", "Shard"
strategy: "RoundRobin",
// Enable local action call preferring. Always call the local action instance if available.
preferLocal: true,
},
// Settings of Circuit Breaker. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Circuit-Breaker
circuitBreaker: {
// Enable feature
enabled: false,
// Threshold value. 0.5 means that 50% should be failed for tripping.
threshold: 0.5,
// Minimum request count. Below it, CB does not trip.
minRequestCount: 20,
// Number of seconds for time window.
windowTime: 60,
// Number of milliseconds to switch from open to half-open state
halfOpenTime: 10 * 1000,
// A function to check failed requests.
check: (err: Errors.MoleculerError) => err && err.code >= 500,
},
// Settings of bulkhead feature. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Bulkhead
bulkhead: {
// Enable feature.
enabled: false,
// Maximum concurrent executions.
concurrency: 10,
// Maximum size of queue
maxQueueSize: 100,
},
// Enable action & event parameter validation. More info: https://moleculer.services/docs/0.14/validating.html
validator: true,
errorHandler: null,
// Enable/disable built-in metrics function. More info: https://moleculer.services/docs/0.14/metrics.html
metrics: {
enabled: true,
// Available built-in reporters: "Console", "CSV", "Event", "Prometheus", "Datadog", "StatsD"
reporter: {
type: "Console",
options: {
// HTTP port
port: 3030,
// HTTP URL path
path: "/metrics",
// Default labels which are appended to all metrics labels
defaultLabels: (registry: MetricRegistry) => ({
namespace: registry.broker.namespace,
nodeID: registry.broker.nodeID,
}),
},
},
},
// Enable built-in tracing function. More info: https://moleculer.services/docs/0.14/tracing.html
tracing: {
enabled: true,
// Available built-in exporters: "Console", "Datadog", "Event", "EventLegacy", "Jaeger", "Zipkin"
exporter: {
type: "Console", // Console exporter is only for development!
options: {
// Custom logger
logger: null,
// Using colors
colors: true,
// Width of row
width: 100,
// Gauge width in the row
gaugeWidth: 40,
},
},
},
// Register custom middlewares
middlewares: [],
// Register custom REPL commands.
replCommands: null,
/*
// Called after broker created.
created : (broker: ServiceBroker): void => {},
// Called after broker started.
started: async (broker: ServiceBroker): Promise<void> => {},
stopped: async (broker: ServiceBroker): Promise<void> => {},
*/
};
export = brokerConfig;

8718
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@@ -0,0 +1,68 @@
{
"name": "comicvine-service",
"version": "1.0.0",
"description": "My Moleculer-based microservices project",
"scripts": {
"build": "tsc --build tsconfig.json",
"dev": "ts-node ./node_modules/moleculer/bin/moleculer-runner.js --hot --repl --config moleculer.config.ts services/**/*.service.ts",
"start": "moleculer-runner --config dist/moleculer.config.js",
"cli": "moleculer connect NATS",
"ci": "jest --watch",
"test": "jest --coverage",
"lint": "eslint --ext .js,.ts .",
"dc:up": "docker-compose up --build -d",
"dc:logs": "docker-compose logs -f",
"dc:down": "docker-compose down"
},
"keywords": [
"microservices",
"moleculer"
],
"author": "",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"axios": "^0.21.1",
"eslint": "^6.8.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-prefer-arrow": "^1.2.2",
"jest": "^25.1.0",
"jest-cli": "^25.1.0",
"moleculer-repl": "^0.6.2",
"threetwo-ui-typings": "^1.0.1-0",
"ts-jest": "^25.3.0",
"ts-node": "^8.8.1"
},
"dependencies": {
"@types/jest": "^25.1.4",
"@types/mkdirp": "^1.0.0",
"@types/node": "^13.9.8",
"moleculer": "^0.14.0",
"moleculer-web": "^0.9.0",
"nats": "^1.3.2",
"typescript": "^3.8.3"
},
"engines": {
"node": ">= 10.x.x"
},
"jest": {
"coverageDirectory": "<rootDir>/coverage",
"testEnvironment": "node",
"moduleFileExtensions": [
"ts",
"tsx",
"js"
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
},
"testMatch": [
"**/*.spec.(ts|js)"
],
"globals": {
"ts-jest": {
"tsConfig": "tsconfig.json"
}
}
}
}

BIN
public/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

716
public/index.html Normal file
View File

@@ -0,0 +1,716 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,minimal-ui">
<title>comicvine-service - Moleculer Microservices Project</title>
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,700" rel="stylesheet">
<link href="https://unpkg.com/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<link rel="shortcut icon" type="image/png" href="https://moleculer.services/icon/favicon-16x16.png"/>
<script src="https://unpkg.com/vue@2.6.11/dist/vue.js"></script>
<style type="text/css">
html {
font-family: "Source Sans Pro", Helvetica, Arial, sans-serif;
font-size: 18px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
padding: 0;
margin: 0;
color: black;
text-shadow: 1px 1px 3px rgba(0,0,0,0.2);
padding-bottom: 60px; /* footer */
}
.cursor-pointer {
cursor: pointer;
user-select: none;
}
header, footer {
text-align: center;
color: white;
text-shadow: 1px 1px 3px rgba(0,0,0,0.6);
}
header a, header a.router-link-exact-active,
footer a, footer a.router-link-exact-active
{
color: #63dcfd;
}
header {
background-image: linear-gradient(45deg, #e37682 0%, #5f4d93 100%);
padding: 1em;
box-shadow: 0 3px 10px rgba(0,0,0,0.6);
margin-bottom: 1em;
}
footer {
background-image: linear-gradient(135deg, #e37682 0%, #5f4d93 100%);
padding: 0.75em;
font-size: 0.8em;
box-shadow: 0 -3px 10px rgba(0,0,0,0.6);
position: fixed;
left: 0; right: 0; bottom: 0;
margin-top: 1em;
}
footer .footer-links {
margin-top: 0.5em;
}
footer .footer-links a {
margin: 0 0.5em;
}
a, a.router-link-exact-active {
color: #3CAFCE;
text-decoration: none;
}
nav ul {
list-style: none;
padding: 0;
margin: 0;
}
nav ul li {
display: inline-block;
padding: 0.25em 0.75em;
cursor: pointer;
font-weight: 300;
font-size: 1.25em;
border-bottom: 2px solid transparent;
transition: color .1s linear, border-bottom .1s linear;
}
nav ul li.active {
border-bottom: 2px solid #63dcfd;
}
nav ul li:hover {
color: #63dcfd;
}
button, .button {
background-color: #3CAFCE;
border: 0;
border-radius: 8px;
color: white;
font-family: "Source Sans Pro", Helvetica, Arial, sans-serif;
font-size: 16px;
font-weight: 400;
padding: 0.5em 1em;
box-shadow: 0 4px 6px -1px rgba(0,0,0,.2);
cursor: pointer;
user-select: none;
}
button i, .button i {
margin-right: 0.5em;
}
button:hover, .button:hover {
background-color: #4ba3bb;
}
code {
font-family: "Consolas", 'Courier New', Courier, monospace;
color: #555;
}
main {
max-width: 1260px;
margin: 0 auto;
padding: 1em 1em;
}
main section#home > .content {
text-align: center;
}
main section#home h1 {
font-size: 2em;
font-weight: 400;
margin-top: 0;
}
main section#home h3 {
font-size: 1.25em;
font-weight: 600;
}
pre.broker-options {
display: inline-block;
text-align: left;
font-size: 0.9em;
}
.boxes {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.boxes .box {
width: 200px;
padding: 0.25em 1em;
margin: 0.5em;
background: rgba(60, 175, 206, 0.1);
border: 1px solid grey;
border-radius: 0.25em;
}
.boxes .box .caption {
font-weight: 300;
font-size: 0.9em;
margin-bottom: 0.5em;
}
.boxes .box .value {
font-weight: 600;
font-size: 1.1em;
}
main input {
border: 1px solid #3CAFCE;
border-radius: 4px;
padding: 2px 6px;
font-family: "Source Sans Pro";
}
main fieldset {
border: 1px solid lightgrey;
border-radius: 8px;
box-shadow: 2px 2px 10px rgba(0,0,0,0.4);
background-color: aliceblue;
margin-bottom: 2em;
}
main fieldset legend {
background-color: #cce7ff;
border: 1px solid lightgrey;
padding: 4px 10px;
border-radius: 8px;
}
main fieldset .content {
padding-left: 2em;
}
main fieldset .request {
margin-bottom: 0.5em;
}
main fieldset .parameters .field {
margin-bottom: 0.25em;
}
main fieldset .parameters .field label {
min-width: 80px;
display: inline-block;
text-align: right;
margin-right: 0.5em;
}
main fieldset .response {
margin-top: 1em;
}
main fieldset .response pre {
margin: 0.5em 0;
font-size: 0.9em;
}
pre.json .string { color: #885800; }
pre.json .number { color: blue; }
pre.json .boolean { color: magenta; }
pre.json .null { color: red; }
pre.json .key { color: green; }
main h4 {
font-weight: 600;
margin: 0.25em -1.0em;
}
.badge {
display: inline-block;
background-color: dimgray;
color: white;
padding: 2px 6px;
border-radius: 7px;
font-size: 0.7em;
font-weight: 600;
}
.badge.green {
background-color: limegreen;
}
.badge.red {
background-color: firebrick;
}
.badge.orange {
background-color: #fab000;
color: black;
}
table {
width: 100%;
/*max-width: 1000px;*/
border: 1px solid lightgrey;
border-radius: 8px;
background-color: aliceblue;
}
table th {
padding: 2px 4px;
background-color: #cce7ff;
border-radius: 4px;
}
table tr.offline td {
font-style: italic;
color: #777;
}
table tr.local td {
/*color: blue;*/
}
table tr:not(:last-child) td {
border-bottom: 1px solid #ddd;
}
table td {
text-align: center;
position: relative;
padding: 2px 4px;
}
table th:nth-child(1), table td:nth-child(1) {
text-align: left
}
table tr.service td:nth-child(1) {
font-weight: bold;
}
table tr.action td:nth-child(1) {
padding-left: 2em;
}
table tr td:nth-child(2) {
font-family: monospace;
font-size: 0.8em;
}
.bar {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
width: 0;
height: 100%;
background-color: rgba(0,0,0,0.3);
}
</style>
</head>
<body>
<div id="app">
<header>
<a href="https://moleculer.services/docs/0.14/" target="_blank"><img class="logo" src="https://moleculer.services/images/logo/logo_with_text_horizontal_100h_shadow.png" /></a>
<nav>
<ul>
<li v-for="item in menu" :class="{ active: page == item.id}" @click="changePage(item.id)">{{ item.caption }}</li>
</ul>
</nav>
</header>
<main>
<section id="home" v-if="page == 'home'">
<div class="content">
<h1>Welcome to your Moleculer microservices project!</h1>
<p>Check out the <a href="https://moleculer.services/docs/0.14/" target="_blank">Moleculer documentation</a> to learn how to customize this project.</p>
<template v-if="broker">
<h3>Configuration</h3>
<div class="boxes">
<div class="box">
<div class="caption">Namespace</div>
<div class="value">{{ broker.namespace || "&lt;not set&gt;" }}</div>
</div>
<div class="box">
<div class="caption">Transporter</div>
<div class="value">{{ broker.transporter || "&lt;no transporter&gt;" }}</div>
</div>
<div class="box">
<div class="caption">Serializer</div>
<div class="value">{{ broker.serializer || "JSON" }}</div>
</div>
<div class="box">
<div class="caption">Strategy</div>
<div class="value">{{ broker.registry.strategy || "Round Robin" }}</div>
</div>
<div class="box">
<div class="caption">Cacher</div>
<div class="value">{{ broker.cacher ? "Enabled" : "Disabled" }}</div>
</div>
<div class="box">
<div class="caption">Logger</div>
<div class="value">{{ broker.logger ? "Enabled" : "Disabled" }}</div>
</div>
<div class="box">
<div class="caption">Metrics</div>
<div class="value">{{ broker.metrics.enabled ? "Enabled" : "Disabled" }}</div>
</div>
<div class="box">
<div class="caption">Tracing</div>
<div class="value">{{ broker.tracing.enabled ? "Enabled" : "Disabled" }}</div>
</div>
</div>
<h3 class="cursor-pointer" @click="showBrokerOptions = !showBrokerOptions">Broker options <i :class="'fa fa-angle-' + (showBrokerOptions ? 'up' : 'down')"></i></h3>
<pre v-if="showBrokerOptions" class="broker-options"><code>{{ broker }}</code></pre>
</template>
</div>
</section>
<template v-for="(section, name) in requests">
<section :id="name" v-if="page == name">
<fieldset v-for="item in section">
<legend>
Action '<code>{{ item.action }}</code>'
</legend>
<div class="content">
<div class="request">
<h4>Request:</h4>
<code>{{ item.method || 'GET' }} <a target="_blank" :href="item.rest">{{ item.rest }} </a></code>
<a class="button" @click="callAction(item)">
<i class="fa fa-rocket"></i>
Execute
</a>
</div>
<div v-if="item.fields" class="parameters">
<h4>Parameters:</h4>
<div class="field" v-for="field in item.fields">
<label for="">{{ field.label }}: </label>
<input :type="field.type" :value="getFieldValue(field)" @input="setFieldValue(field, $event.target.value)"></input>
</div>
</div>
<div class="response" v-if="item.status">
<h4>Response:
<div class="badge" :class="{ green: item.status < 400, red: item.status >= 400 || item.status == 'ERR' }">{{ item.status }}</div>
<div class="badge time">{{ humanize(item.duration) }}</div>
</h4>
<pre><code>{{ item.response }}</code></pre>
</div>
</div>
</fieldset>
</section>
</template>
<section id="nodes" v-if="page == 'nodes'">
<table>
<thead>
<th>Node ID</th>
<th>Type</th>
<th>Version</th>
<th>IP</th>
<th>Hostname</th>
<th>Status</th>
<th>CPU</th>
</thead>
<tbody>
<tr v-for="node in nodes" :class="{ offline: !node.available, local: node.local }" :key="node.id">
<td>{{ node.id }}</td>
<td>{{ node.client.type }}</td>
<td>{{ node.client.version }}</td>
<td>{{ node.ipList[0] }}</td>
<td>{{ node.hostname }}</td>
<td><div class="badge" :class="{ green: node.available, red: !node.available }">{{ node.available ? "Online": "Offline" }}</div></td>
<td>
<div class="bar" :style="{ width: node.cpu != null ? node.cpu + '%' : '0' }"></div>
{{ node.cpu != null ? Number(node.cpu).toFixed(0) + '%' : '-' }}
</td>
</tr>
</tbody>
</table>
</section>
<section id="services" v-if="page == 'services'">
<table>
<thead>
<th>Service/Action name</th>
<th>REST</th>
<th>Parameters</th>
<th>Instances</th>
<th>Status</th>
</thead>
<tbody>
<template v-for="svc in filteredServices">
<tr class="service">
<td>
{{ svc.name }}
<div v-if="svc.version" class="badge">{{ svc.version }}</div>
</td>
<td>{{ svc.settings.rest ? svc.settings.rest : svc.fullName }}</td>
<td></td>
<td class="badges">
<div class="badge" v-for="nodeID in svc.nodes">
{{ nodeID }}
</div>
</td>
<td>
<div v-if="svc.nodes.length > 0" class="badge green">Online</div>
<div v-else class="badge red">Offline</div>
</td>
</tr>
<tr v-for="action in getServiceActions(svc)" :class="{ action: true, offline: !action.available, local: action.hasLocal }">
<td>
{{ action.name }}
<div v-if="action.action.cache" class="badge orange">cached</div>
</td>
<td v-html="getActionREST(svc, action)"></td>
<td :title="getActionParams(action)">
{{ getActionParams(action, 40) }}
</td>
<td></td>
<td>
<div v-if="action.available" class="badge green">Online</div>
<div v-else class="badge red">Offline</div>
</td>
</tr>
</template>
</tbody>
</table>
</section>
</main>
<footer>
<div class="footer-copyright">
Copyright &copy; 2016-2020 - Moleculer
</div>
<div class="footer-links">
<a href="https://github.com/moleculerjs/moleculer" class="footer-link" target="_blank">Github</a>
<a href="https://twitter.com/MoleculerJS" class="footer-link" target="_blank">Twitter</a>
<a href="https://discord.gg/TSEcDRP" class="footer-link" target="_blank">Discord</a>
<a href="https://stackoverflow.com/questions/tagged/moleculer" class="footer-link" target="_blank">Stack Overflow</a>
</div>
</footer>
</div>
<script type="text/javascript">
var app = new Vue({
el: "#app",
data() {
return {
menu: [
{ id: "home", caption: "Home" },
{ id: "greeter", caption: "Greeter service" },
{ id: "nodes", caption: "Nodes" },
{ id: "services", caption: "Services" }
],
page: "home",
requests: {
greeter: [
{ id: "hello", action: "greeter.hello", rest: "/api/greeter/hello", response: null, status: null, duration: null },
{ id: "welcome", action: "greeter.welcome", rest: "/api/greeter/welcome", fields: [
{ field: "name", label: "Name", type: "text", paramType: "param", model: "welcomeName" }
], response: null, status: null, duration: null }
]
},
fields: {
welcomeName: "John"
},
broker: null,
nodes: [],
services: [],
actions: {},
showBrokerOptions: false
};
},
computed: {
filteredServices() {
return this.services.filter(svc => !svc.name.startsWith("$"));
}
},
methods: {
changePage(page) {
this.page = page;
this.updatePageResources();
},
humanize(ms) {
return ms > 1500 ? (ms / 1500).toFixed(2) + " s" : ms + " ms";
},
getFieldValue(field) {
return this.fields[field.model];
},
setFieldValue(field, newValue) {
if (field.type == "number")
this.fields[field.model] = Number(newValue);
else
this.fields[field.model] = newValue;
},
getServiceActions(svc) {
return Object.keys(svc.actions)
.map(name => this.actions[name])
.filter(action => !!action);
},
getActionParams(action, maxLen) {
if (action.action && action.action.params) {
const s = Object.keys(action.action.params).join(", ");
return s.length > maxLen ? s.substr(0, maxLen) + "…" : s;
}
return "-";
},
getActionREST(svc, action) {
if (action.action.rest) {
let prefix = svc.fullName || svc.name;
if (typeof(svc.settings.rest) == "string")
prefix = svc.settings.rest;
if (typeof action.action.rest == "string") {
if (action.action.rest.indexOf(" ") !== -1) {
const p = action.action.rest.split(" ");
return "<span class='badge'>" + p[0] + "</span> " + prefix + p[1];
} else {
return "<span class='badge'>*</span> " + prefix + action.action.rest;
}
} else {
return "<span class='badge'>" + (action.action.rest.method || "*") + "</span> " + prefix + action.action.rest.path;
}
}
return "";
},
callAction: function (item) {
var startTime = Date.now();
let url = item.rest;
const method = item.method || "GET";
let body = null;
let params = null;
if (item.fields) {
body = {};
params = {};
item.fields.forEach(field => {
const value = this.getFieldValue(field);
if (field.paramType == "body")
body[field.field] = value;
else if (field.paramType == "param")
params[field.field] = value;
else if (field.paramType == "url")
url = url.replace(":" + field.field, value);
});
if (body && method == "GET") {
body = null;
}
if (params) {
url += "?" + new URLSearchParams(params).toString();
}
}
return fetch(url, {
method,
body: body ? JSON.stringify(body) : null,
headers: {
'Content-Type': 'application/json'
}
}).then(function(res) {
item.status = res.status;
item.duration = Date.now() - startTime;
return res.json().then(json => {
item.response = json;
if (item.afterResponse)
return item.afterResponse(json);
});
}).catch(function (err) {
item.status = "ERR";
item.duration = Date.now() - startTime;
item.response = err.message;
console.log(err);
});
},
updateBrokerOptions: function (name) {
this.req("/api/~node/options", null).then(res => this.broker = res);
},
updateNodeList: function (name) {
this.req("/api/~node/list", null).then(res => {
res.sort((a,b) => a.id.localeCompare(b.id));
this.nodes = res;
});
},
updateServiceList: function (name) {
this.req("/api/~node/services?withActions=true", null)
.then(res => {
this.services = res;
res.sort((a,b) => a.name.localeCompare(b.name));
res.forEach(svc => svc.nodes.sort());
})
.then(() => this.req("/api/~node/actions", null))
.then(res => {
res.sort((a,b) => a.name.localeCompare(b.name));
const actions = res.reduce((a,b) => {
a[b.name] = b;
return a;
}, {});
Vue.set(this, "actions", actions);
});
},
req: function (url, params) {
return fetch(url, { method: "GET", body: params ? JSON.stringify(params) : null })
.then(function(res) {
return res.json();
});
},
updatePageResources() {
if (this.page == 'nodes') return this.updateNodeList();
if (this.page == 'services') return this.updateServiceList();
}
},
mounted() {
var self = this;
setInterval(function () {
self.updatePageResources();
}, 2000);
this.updateBrokerOptions();
}
});
</script>
</body>
</html>

174
services/api.service.ts Normal file
View File

@@ -0,0 +1,174 @@
import {IncomingMessage} from "http";
import {Service, ServiceBroker, Context} from "moleculer";
import ApiGateway from "moleculer-web";
export default class ApiService extends Service {
public constructor(broker: ServiceBroker) {
super(broker);
// @ts-ignore
this.parseServiceSchema({
name: "api",
mixins: [ApiGateway],
// More info about settings: https://moleculer.services/docs/0.14/moleculer-web.html
settings: {
port: process.env.PORT || 3000,
routes: [{
path: "/api",
whitelist: [
// Access to any actions in all services under "/api" URL
"**",
],
// Route-level Express middlewares. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Middlewares
use: [],
// Enable/disable parameter merging method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Disable-merging
mergeParams: true,
// Enable authentication. Implement the logic into `authenticate` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authentication
authentication: false,
// Enable authorization. Implement the logic into `authorize` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authorization
authorization: false,
// The auto-alias feature allows you to declare your route alias directly in your services.
// The gateway will dynamically build the full routes from service schema.
autoAliases: true,
aliases:{},
/**
* Before call hook. You can check the request.
* @param {Context} ctx
* @param {Object} route
* @param {IncomingMessage} req
* @param {ServerResponse} res
* @param {Object} data
onBeforeCall(ctx: Context<any,{userAgent: string}>,
route: object, req: IncomingMessage, res: ServerResponse) {
Set request headers to context meta
ctx.meta.userAgent = req.headers["user-agent"];
},
*/
/**
* After call hook. You can modify the data.
* @param {Context} ctx
* @param {Object} route
* @param {IncomingMessage} req
* @param {ServerResponse} res
* @param {Object} data
*
onAfterCall(ctx: Context, route: object, req: IncomingMessage, res: ServerResponse, data: object) {
// Async function which return with Promise
return doSomething(ctx, res, data);
},
*/
// Calling options. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Calling-options
callingOptions: {},
bodyParsers: {
json: {
strict: false,
limit: "1MB",
},
urlencoded: {
extended: true,
limit: "1MB",
},
},
// Mapping policy setting. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Mapping-policy
mappingPolicy: "all", // Available values: "all", "restrict"
// Enable/disable logging
logging: true,
}],
// Do not log client side errors (does not log an error response when the error.code is 400<=X<500)
log4XXResponses: false,
// Logging the request parameters. Set to any log level to enable it. E.g. "info"
logRequestParams: null,
// Logging the response data. Set to any log level to enable it. E.g. "info"
logResponseData: null,
// Serve assets from "public" folder
assets: {
folder: "public",
// Options to `server-static` module
options: {},
},
},
methods: {
/**
* Authenticate the request. It checks the `Authorization` token value in the request header.
* Check the token value & resolve the user by the token.
* The resolved user will be available in `ctx.meta.user`
*
* PLEASE NOTE, IT'S JUST AN EXAMPLE IMPLEMENTATION. DO NOT USE IN PRODUCTION!
*
* @param {Context} ctx
* @param {any} route
* @param {IncomingMessage} req
* @returns {Promise}
async authenticate (ctx: Context, route: any, req: IncomingMessage): Promise < any > => {
// Read the token from header
const auth = req.headers.authorization;
if (auth && auth.startsWith("Bearer")) {
const token = auth.slice(7);
// Check the token. Tip: call a service which verify the token. E.g. `accounts.resolveToken`
if (token === "123456") {
// Returns the resolved user. It will be set to the `ctx.meta.user`
return {
id: 1,
name: "John Doe",
};
} else {
// Invalid token
throw new ApiGateway.Errors.UnAuthorizedError(ApiGateway.Errors.ERR_INVALID_TOKEN, {
error: "Invalid Token",
});
}
} else {
// No token. Throw an error or do nothing if anonymous access is allowed.
// Throw new E.UnAuthorizedError(E.ERR_NO_TOKEN);
return null;
}
},
*/
/**
* Authorize the request. Check that the authenticated user has right to access the resource.
*
* PLEASE NOTE, IT'S JUST AN EXAMPLE IMPLEMENTATION. DO NOT USE IN PRODUCTION!
*
* @param {Context} ctx
* @param {Object} route
* @param {IncomingMessage} req
* @returns {Promise}
async authorize (ctx: Context < any, {
user: string;
} > , route: Record<string, undefined>, req: IncomingMessage): Promise < any > => {
// Get the authenticated user.
const user = ctx.meta.user;
// It check the `auth` property in action schema.
// @ts-ignore
if (req.$action.auth === "required" && !user) {
throw new ApiGateway.Errors.UnAuthorizedError("NO_RIGHTS", {
error: "Unauthorized",
});
}
},
*/
},
});
}
}

View File

@@ -0,0 +1,62 @@
"use strict";
import { Service, ServiceBroker, Context } from "moleculer";
import axios from "axios";
const CV_BASE_URL = "https://comicvine.gamespot.com/api/";
const CV_API_KEY = "a5fa0663683df8145a85d694b5da4b87e1c92c69";
export default class GreeterService extends Service {
public constructor(public broker: ServiceBroker) {
super(broker);
this.parseServiceSchema({
name: "comicvine",
actions: {
fetchSeries: {
rest: "/fetchseries",
params: {
format: { type: "string", optional: false },
sort: { type: "string", optional: true },
query: { type: "string", optional: false },
fieldList: { type: "string", optional: true },
limit: { type: "string", optional: false },
offset: { type: "string", optional: false },
resources: { type: "string", optional: false },
},
handler: async (
ctx: Context<{
format: string;
sort: string;
query: string;
fieldList: string;
limit: string;
offset: string;
resources: string;
}>
): Promise<any> => {
const response = await axios.request({
url:
CV_BASE_URL +
"search" +
"?api_key=" +
CV_API_KEY,
params: ctx.params,
transformResponse: (r) => JSON.parse(r),
headers: { Accept: "application/json" },
});
const { data } = response;
return data;
},
},
},
});
}
// Action
public ActionHello(): string {
return "Hello Moleculer";
}
public ActionWelcome(name: string): string {
return `Welcome, ${name}`;
}
}

View File

@@ -0,0 +1,85 @@
"use strict";
import { ServiceBroker } from "moleculer";
import DbService from "moleculer-db";
import DbMixin from "../../../mixins/db.mixin";
describe("Test DB mixin", () => {
describe("Test schema generator", () => {
const broker = new ServiceBroker({ logger: false, cacher: "Memory" });
beforeAll(() => broker.start());
afterAll(() => broker.stop());
it("check schema properties", async () => {
const schema = new DbMixin("my-collection").start();
expect(schema.mixins).toEqual([DbService]);
// @ts-ignore
expect(schema.adapter).toBeInstanceOf(DbService.MemoryAdapter);
expect(schema.started).toBeDefined();
expect(schema.events["cache.clean.my-collection"]).toBeInstanceOf(Function);
});
it("check cache event handler", async () => {
jest.spyOn(broker.cacher, "clean");
const schema = new DbMixin("my-collection").start();
// @ts-ignore
await schema.events["cache.clean.my-collection"].call({ broker, fullName: "my-service" });
expect(broker.cacher.clean).toBeCalledTimes(1);
expect(broker.cacher.clean).toBeCalledWith("my-service.*");
});
describe("Check service started handler", () => {
it("should not call seedDB method", async () => {
const schema = new DbMixin("my-collection").start();
schema.adapter.count = jest.fn(async () => 10);
const seedDBFn = jest.fn();
// @ts-ignore
await schema.started.call({ broker, logger: broker.logger, adapter: schema.adapter, seedDB: seedDBFn });
expect(schema.adapter.count).toBeCalledTimes(1);
expect(schema.adapter.count).toBeCalledWith();
expect(seedDBFn).toBeCalledTimes(0);
});
it("should call seedDB method", async () => {
const schema = new DbMixin("my-collection").start();
schema.adapter.count = jest.fn(async () => 0);
const seedDBFn = jest.fn();
// @ts-ignore
await schema.started.call({ broker, logger: broker.logger, adapter: schema.adapter, seedDB: seedDBFn });
expect(schema.adapter.count).toBeCalledTimes(2);
expect(schema.adapter.count).toBeCalledWith();
expect(seedDBFn).toBeCalledTimes(1);
expect(seedDBFn).toBeCalledWith();
});
});
it("should broadcast a cache clear event", async () => {
const schema = new DbMixin("my-collection").start();
const ctx = {
broadcast: jest.fn(),
};
await schema.methods.entityChanged(null, null, ctx);
expect(ctx.broadcast).toBeCalledTimes(1);
expect(ctx.broadcast).toBeCalledWith("cache.clean.my-collection");
});
});
});

View File

@@ -0,0 +1,40 @@
"use strict";
import { Errors, ServiceBroker} from "moleculer";
import TestService from "../../../services/greeter.service";
describe("Test 'greeter' service", () => {
const broker = new ServiceBroker({ logger: false });
broker.createService(TestService);
beforeAll(() => broker.start());
afterAll(() => broker.stop());
describe("Test 'greeter.hello' action", () => {
it("should return with 'Hello Moleculer'", async () => {
const res = await broker.call("greeter.hello");
expect(res).toBe("Hello Moleculer");
});
});
describe("Test 'greeter.welcome' action", () => {
it("should return with 'Welcome'", async () => {
const res = await broker.call("greeter.welcome", { name: "Adam" });
expect(res).toBe("Welcome, Adam");
});
it("should reject an ValidationError", async () => {
expect.assertions(1);
try {
await broker.call("greeter.welcome");
} catch (err) {
expect(err).toBeInstanceOf(Errors.ValidationError);
}
});
});
});

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"pretty": true,
"target": "es6",
"outDir": "dist"
},
"include": ["./**/*"],
"exclude": [
"node_modules/**/*",
"test"
]
}