642 lines
20 KiB
TypeScript
642 lines
20 KiB
TypeScript
import express, { Request, Response } from "express";
|
|
import swaggerUi from "swagger-ui-express";
|
|
import { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
|
|
import { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
|
|
import { buildODataParams, extractNextLink } from "./client/odata";
|
|
import { loadSapB1ConfigFromEnv, SapB1Config, SapB1ConfigSchema } from "./config";
|
|
import { SapB1Error } from "./errors/SapB1Error";
|
|
import { ODataQuery } from "./types/odata";
|
|
|
|
export { SessionManager, SapB1Session } from "./auth/SessionManager";
|
|
export { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
|
|
export { buildODataParams, extractNextLink } from "./client/odata";
|
|
export { loadSapB1ConfigFromEnv, SapB1Config, SapB1ConfigSchema } from "./config";
|
|
export { SapB1Error } from "./errors/SapB1Error";
|
|
export { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
|
|
export * from "./types/entities";
|
|
export * from "./types/odata";
|
|
|
|
type ResourceName =
|
|
| "businessPartners"
|
|
| "items"
|
|
| "orders"
|
|
| "invoices"
|
|
| "purchaseOrders"
|
|
| "deliveryNotes"
|
|
| "stockTransfers";
|
|
|
|
interface ResourceRoute {
|
|
slug: string;
|
|
tag: string;
|
|
property: ResourceName;
|
|
sapEntitySet: string;
|
|
idName: string;
|
|
idType: "string" | "number";
|
|
deleteSupported: boolean;
|
|
sampleCreate: Record<string, unknown>;
|
|
sampleUpdate: Record<string, unknown>;
|
|
}
|
|
|
|
const app = express();
|
|
const port = Number(process.env.PORT || 3000);
|
|
const rootPath = process.env.ROOT_PATH || "";
|
|
const serviceName = "SAP Business One";
|
|
const serviceId = "sap-bo";
|
|
|
|
const resourceRoutes: ResourceRoute[] = [
|
|
{
|
|
slug: "business-partners",
|
|
tag: "BusinessPartners",
|
|
property: "businessPartners",
|
|
sapEntitySet: "BusinessPartners",
|
|
idName: "CardCode",
|
|
idType: "string",
|
|
deleteSupported: true,
|
|
sampleCreate: { CardCode: "C001", CardName: "Example customer", CardType: "cCustomer" },
|
|
sampleUpdate: { CardName: "Updated customer name" }
|
|
},
|
|
{
|
|
slug: "items",
|
|
tag: "Items",
|
|
property: "items",
|
|
sapEntitySet: "Items",
|
|
idName: "ItemCode",
|
|
idType: "string",
|
|
deleteSupported: true,
|
|
sampleCreate: { ItemCode: "A00001", ItemName: "Example item", InventoryItem: "tYES" },
|
|
sampleUpdate: { ItemName: "Updated item name" }
|
|
},
|
|
{
|
|
slug: "orders",
|
|
tag: "Orders",
|
|
property: "orders",
|
|
sapEntitySet: "Orders",
|
|
idName: "DocEntry",
|
|
idType: "number",
|
|
deleteSupported: false,
|
|
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
|
|
sampleUpdate: { Comments: "Updated by connector" }
|
|
},
|
|
{
|
|
slug: "invoices",
|
|
tag: "Invoices",
|
|
property: "invoices",
|
|
sapEntitySet: "Invoices",
|
|
idName: "DocEntry",
|
|
idType: "number",
|
|
deleteSupported: false,
|
|
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
|
|
sampleUpdate: { Comments: "Updated by connector" }
|
|
},
|
|
{
|
|
slug: "purchase-orders",
|
|
tag: "PurchaseOrders",
|
|
property: "purchaseOrders",
|
|
sapEntitySet: "PurchaseOrders",
|
|
idName: "DocEntry",
|
|
idType: "number",
|
|
deleteSupported: false,
|
|
sampleCreate: { CardCode: "V001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
|
|
sampleUpdate: { Comments: "Updated by connector" }
|
|
},
|
|
{
|
|
slug: "delivery-notes",
|
|
tag: "DeliveryNotes",
|
|
property: "deliveryNotes",
|
|
sapEntitySet: "DeliveryNotes",
|
|
idName: "DocEntry",
|
|
idType: "number",
|
|
deleteSupported: false,
|
|
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
|
|
sampleUpdate: { Comments: "Updated by connector" }
|
|
},
|
|
{
|
|
slug: "stock-transfers",
|
|
tag: "StockTransfers",
|
|
property: "stockTransfers",
|
|
sapEntitySet: "StockTransfers",
|
|
idName: "DocEntry",
|
|
idType: "number",
|
|
deleteSupported: false,
|
|
sampleCreate: { StockTransferLines: [{ ItemCode: "A00001", Quantity: 1, FromWarehouseCode: "01", WarehouseCode: "02" }] },
|
|
sampleUpdate: { Comments: "Updated by connector" }
|
|
}
|
|
];
|
|
|
|
app.use(express.json({ limit: "2mb" }));
|
|
|
|
function getHeader(req: Request, name: string): string | undefined {
|
|
const value = req.get(name);
|
|
return value && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function buildConfigFromRequest(req: Request): SapB1Config {
|
|
return SapB1ConfigSchema.parse({
|
|
baseUrl: getHeader(req, "X-SAP-B1-BaseUrl"),
|
|
companyDB: getHeader(req, "X-SAP-B1-CompanyDB"),
|
|
username: getHeader(req, "X-SAP-B1-Username"),
|
|
password: getHeader(req, "X-SAP-B1-Password"),
|
|
language: getHeader(req, "X-SAP-B1-Language"),
|
|
timeout: getHeader(req, "X-SAP-B1-Timeout-Ms") ? Number(getHeader(req, "X-SAP-B1-Timeout-Ms")) : undefined,
|
|
rejectUnauthorized: getHeader(req, "X-SAP-B1-Reject-Unauthorized")
|
|
? getHeader(req, "X-SAP-B1-Reject-Unauthorized") !== "false"
|
|
: undefined,
|
|
retryCount: getHeader(req, "X-SAP-B1-Retry-Count") ? Number(getHeader(req, "X-SAP-B1-Retry-Count")) : undefined,
|
|
retryDelayMs: getHeader(req, "X-SAP-B1-Retry-Delay-Ms") ? Number(getHeader(req, "X-SAP-B1-Retry-Delay-Ms")) : undefined
|
|
});
|
|
}
|
|
|
|
function getSap(req: Request): SapBusinessOneServiceLayer {
|
|
return new SapBusinessOneServiceLayer(buildConfigFromRequest(req));
|
|
}
|
|
|
|
async function withSap<T>(req: Request, action: (sap: SapBusinessOneServiceLayer) => Promise<T>): Promise<T> {
|
|
const sap = getSap(req);
|
|
|
|
try {
|
|
return await action(sap);
|
|
} finally {
|
|
await sap.logout().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
function parseODataQuery(req: Request): ODataQuery {
|
|
const query: ODataQuery = {};
|
|
const select = req.query.$select ?? req.query.select;
|
|
const filter = req.query.$filter ?? req.query.filter;
|
|
const top = req.query.$top ?? req.query.top;
|
|
const skip = req.query.$skip ?? req.query.skip;
|
|
const orderby = req.query.$orderby ?? req.query.orderby;
|
|
|
|
if (typeof select === "string" && select.trim()) {
|
|
query.select = select.split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
if (typeof filter === "string" && filter.trim()) {
|
|
query.filter = filter;
|
|
}
|
|
|
|
if (typeof top === "string" && top.trim()) {
|
|
query.top = Number(top);
|
|
}
|
|
|
|
if (typeof skip === "string" && skip.trim()) {
|
|
query.skip = Number(skip);
|
|
}
|
|
|
|
if (typeof orderby === "string" && orderby.trim()) {
|
|
query.orderby = orderby;
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
function parseId(route: ResourceRoute, rawId: string): string | number {
|
|
return route.idType === "number" ? Number(rawId) : rawId;
|
|
}
|
|
|
|
function routeResource(sap: SapBusinessOneServiceLayer, route: ResourceRoute) {
|
|
return sap[route.property];
|
|
}
|
|
|
|
function asyncHandler(handler: (req: Request, res: Response) => Promise<void>) {
|
|
return (req: Request, res: Response) => {
|
|
handler(req, res).catch((error) => sendError(res, error));
|
|
};
|
|
}
|
|
|
|
function sendError(res: Response, error: unknown): void {
|
|
if (error instanceof SapB1Error) {
|
|
res.status(error.status || 502).json({
|
|
error: {
|
|
type: error.name,
|
|
code: error.code,
|
|
message: error.message,
|
|
retryable: error.retryable
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
res.status(400).json({ error: { type: error.name, message: error.message } });
|
|
return;
|
|
}
|
|
|
|
res.status(500).json({ error: { type: "Error", message: "Unexpected error" } });
|
|
}
|
|
|
|
function serviceMetadata() {
|
|
return {
|
|
name: serviceName,
|
|
service: serviceId,
|
|
status: "ok",
|
|
resources: resourceRoutes.map((route) => route.slug)
|
|
};
|
|
}
|
|
|
|
function addResourceEndpoints(prefix = "") {
|
|
app.post(
|
|
`${prefix}/api/session/login`,
|
|
asyncHandler(async (req, res) => {
|
|
await withSap(req, (sap) => sap.login());
|
|
res.json({ status: "ok" });
|
|
})
|
|
);
|
|
|
|
app.post(
|
|
`${prefix}/api/session/logout`,
|
|
asyncHandler(async (req, res) => {
|
|
await getSap(req).logout();
|
|
res.status(204).send();
|
|
})
|
|
);
|
|
|
|
for (const route of resourceRoutes) {
|
|
const base = `${prefix}/api/${route.slug}`;
|
|
|
|
app.get(
|
|
base,
|
|
asyncHandler(async (req, res) => {
|
|
const result = await withSap<unknown>(req, (sap) => routeResource(sap, route).list(parseODataQuery(req)));
|
|
res.json(result);
|
|
})
|
|
);
|
|
|
|
app.get(
|
|
`${base}/all`,
|
|
asyncHandler(async (req, res) => {
|
|
const result = await withSap<unknown[]>(req, (sap) => routeResource(sap, route).listAll(parseODataQuery(req)));
|
|
res.json({ value: result });
|
|
})
|
|
);
|
|
|
|
app.get(
|
|
`${base}/:id`,
|
|
asyncHandler(async (req, res) => {
|
|
const result = await withSap<unknown>(req, (sap) => routeResource(sap, route).get(parseId(route, req.params.id) as never));
|
|
res.json(result);
|
|
})
|
|
);
|
|
|
|
app.post(
|
|
base,
|
|
asyncHandler(async (req, res) => {
|
|
const result = await withSap<unknown>(req, (sap) => routeResource(sap, route).create(req.body as never));
|
|
res.status(201).json(result);
|
|
})
|
|
);
|
|
|
|
app.patch(
|
|
`${base}/:id`,
|
|
asyncHandler(async (req, res) => {
|
|
await withSap(req, (sap) => routeResource(sap, route).update(parseId(route, req.params.id) as never, req.body as never));
|
|
res.status(204).send();
|
|
})
|
|
);
|
|
|
|
if (route.deleteSupported) {
|
|
app.delete(
|
|
`${base}/:id`,
|
|
asyncHandler(async (req, res) => {
|
|
await withSap(req, (sap) => routeResource(sap, route).delete(parseId(route, req.params.id) as never));
|
|
res.status(204).send();
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function commonParameters(route: ResourceRoute) {
|
|
return [
|
|
{
|
|
name: "id",
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: route.idType },
|
|
description: `${route.idName} in SAP Business One ${route.sapEntitySet}.`
|
|
}
|
|
];
|
|
}
|
|
|
|
function sapCredentialParameters() {
|
|
return [
|
|
{
|
|
name: "X-SAP-B1-BaseUrl",
|
|
in: "header",
|
|
required: true,
|
|
schema: { type: "string", format: "uri" },
|
|
description: "SAP Business One Service Layer base URL, for example https://sap-host:50000."
|
|
},
|
|
{
|
|
name: "X-SAP-B1-CompanyDB",
|
|
in: "header",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
description: "SAP Business One company database."
|
|
},
|
|
{
|
|
name: "X-SAP-B1-Username",
|
|
in: "header",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
description: "SAP Business One user name."
|
|
},
|
|
{
|
|
name: "X-SAP-B1-Password",
|
|
in: "header",
|
|
required: true,
|
|
schema: { type: "string", format: "password" },
|
|
description: "SAP Business One password. Never logged or returned."
|
|
},
|
|
{
|
|
name: "X-SAP-B1-Language",
|
|
in: "header",
|
|
required: false,
|
|
schema: { type: "string" },
|
|
description: "Optional SAP language code."
|
|
},
|
|
{
|
|
name: "X-SAP-B1-Reject-Unauthorized",
|
|
in: "header",
|
|
required: false,
|
|
schema: { type: "boolean", default: true },
|
|
description: "Set false only for internal self-signed certificates."
|
|
},
|
|
{
|
|
name: "X-SAP-B1-Timeout-Ms",
|
|
in: "header",
|
|
required: false,
|
|
schema: { type: "integer", default: 30000 },
|
|
description: "Request timeout in milliseconds."
|
|
}
|
|
];
|
|
}
|
|
|
|
function odataParameters() {
|
|
return [
|
|
{ name: "$select", in: "query", required: false, schema: { type: "string" }, description: "Comma separated OData field list." },
|
|
{ name: "$filter", in: "query", required: false, schema: { type: "string" }, description: "OData filter expression." },
|
|
{ name: "$top", in: "query", required: false, schema: { type: "integer", minimum: 1 }, description: "Maximum records to return." },
|
|
{ name: "$skip", in: "query", required: false, schema: { type: "integer", minimum: 0 }, description: "Records to skip." },
|
|
{ name: "$orderby", in: "query", required: false, schema: { type: "string" }, description: "OData order by expression." }
|
|
];
|
|
}
|
|
|
|
function jsonBody(example: Record<string, unknown>) {
|
|
return {
|
|
required: true,
|
|
content: {
|
|
"application/json": {
|
|
schema: { type: "object", additionalProperties: true },
|
|
example
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function openApiDocument(basePath = "") {
|
|
const paths: Record<string, unknown> = {
|
|
"/": {
|
|
get: {
|
|
tags: ["Service"],
|
|
summary: "Service metadata",
|
|
responses: {
|
|
"200": { description: "Service status" }
|
|
}
|
|
}
|
|
},
|
|
"/health": {
|
|
get: {
|
|
tags: ["Service"],
|
|
summary: "Health check",
|
|
responses: {
|
|
"200": { description: "Service is ready to accept traffic" }
|
|
}
|
|
}
|
|
},
|
|
"/openapi.json": {
|
|
get: {
|
|
tags: ["Service"],
|
|
summary: "OpenAPI schema",
|
|
responses: {
|
|
"200": { description: "OpenAPI document" }
|
|
}
|
|
}
|
|
},
|
|
"/api/session/login": {
|
|
post: {
|
|
tags: ["Session"],
|
|
summary: "Open SAP Business One Service Layer session",
|
|
description:
|
|
"Uses SAP credentials from X-SAP-B1-* request headers. Credentials are never accepted in the request body, logged, or returned.",
|
|
parameters: sapCredentialParameters(),
|
|
responses: {
|
|
"200": { description: "Session is available" },
|
|
"502": { description: "SAP login failed" }
|
|
}
|
|
}
|
|
},
|
|
"/api/session/logout": {
|
|
post: {
|
|
tags: ["Session"],
|
|
summary: "Logout from SAP Business One Service Layer",
|
|
parameters: sapCredentialParameters(),
|
|
responses: {
|
|
"204": { description: "Session closed or was not active" }
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const route of resourceRoutes) {
|
|
const base = `/api/${route.slug}`;
|
|
paths[base] = {
|
|
get: {
|
|
tags: [route.tag],
|
|
summary: `List ${route.sapEntitySet}`,
|
|
description: `Maps to SAP Service Layer GET /b1s/v1/${route.sapEntitySet}.`,
|
|
parameters: [...sapCredentialParameters(), ...odataParameters()],
|
|
responses: {
|
|
"200": { description: "OData response with value array and optional nextLink" }
|
|
}
|
|
},
|
|
post: {
|
|
tags: [route.tag],
|
|
summary: `Create ${route.sapEntitySet} record`,
|
|
description: `Maps to SAP Service Layer POST /b1s/v1/${route.sapEntitySet}.`,
|
|
parameters: sapCredentialParameters(),
|
|
requestBody: jsonBody(route.sampleCreate),
|
|
responses: {
|
|
"201": { description: "Created SAP object" }
|
|
}
|
|
}
|
|
};
|
|
|
|
paths[`${base}/all`] = {
|
|
get: {
|
|
tags: [route.tag],
|
|
summary: `Load all ${route.sapEntitySet} pages`,
|
|
description: "Follows odata.nextLink/@odata.nextLink until all pages are loaded. Use carefully for large datasets.",
|
|
parameters: [...sapCredentialParameters(), ...odataParameters()],
|
|
responses: {
|
|
"200": { description: "Object with value array containing all loaded records" }
|
|
}
|
|
}
|
|
};
|
|
|
|
const itemOperations: Record<string, unknown> = {
|
|
get: {
|
|
tags: [route.tag],
|
|
summary: `Get ${route.sapEntitySet} by ${route.idName}`,
|
|
description: `Maps to SAP Service Layer GET /b1s/v1/${route.sapEntitySet}(<id>).`,
|
|
parameters: [...sapCredentialParameters(), ...commonParameters(route)],
|
|
responses: {
|
|
"200": { description: "SAP object" },
|
|
"404": { description: "Object was not found by SAP Service Layer" }
|
|
}
|
|
},
|
|
patch: {
|
|
tags: [route.tag],
|
|
summary: `Update ${route.sapEntitySet} by ${route.idName}`,
|
|
description: `Maps to SAP Service Layer PATCH /b1s/v1/${route.sapEntitySet}(<id>).`,
|
|
parameters: [...sapCredentialParameters(), ...commonParameters(route)],
|
|
requestBody: jsonBody(route.sampleUpdate),
|
|
responses: {
|
|
"204": { description: "Updated" }
|
|
}
|
|
}
|
|
};
|
|
|
|
if (route.deleteSupported) {
|
|
itemOperations.delete = {
|
|
tags: [route.tag],
|
|
summary: `Delete ${route.sapEntitySet} by ${route.idName}`,
|
|
description: `Maps to SAP Service Layer DELETE /b1s/v1/${route.sapEntitySet}(<id>). Availability still depends on SAP object state and permissions.`,
|
|
parameters: [...sapCredentialParameters(), ...commonParameters(route)],
|
|
responses: {
|
|
"204": { description: "Deleted" }
|
|
}
|
|
};
|
|
}
|
|
|
|
paths[`${base}/{id}`] = itemOperations;
|
|
}
|
|
|
|
return {
|
|
openapi: "3.0.3",
|
|
info: {
|
|
title: "SAP Business One connector service",
|
|
version: "1.0.0",
|
|
description:
|
|
"HTTP API and TypeScript connector for SAP Business One Service Layer. SAP credentials are supplied per request in X-SAP-B1-* headers."
|
|
},
|
|
servers: [{ url: basePath || "/" }],
|
|
security: [
|
|
{
|
|
SapB1BaseUrl: [],
|
|
SapB1CompanyDB: [],
|
|
SapB1Username: [],
|
|
SapB1Password: []
|
|
}
|
|
],
|
|
tags: [
|
|
{ name: "Service" },
|
|
{ name: "Session" },
|
|
...resourceRoutes.map((route) => ({ name: route.tag, description: `SAP Service Layer ${route.sapEntitySet}` }))
|
|
],
|
|
paths,
|
|
components: {
|
|
securitySchemes: {
|
|
SapB1BaseUrl: { type: "apiKey", in: "header", name: "X-SAP-B1-BaseUrl" },
|
|
SapB1CompanyDB: { type: "apiKey", in: "header", name: "X-SAP-B1-CompanyDB" },
|
|
SapB1Username: { type: "apiKey", in: "header", name: "X-SAP-B1-Username" },
|
|
SapB1Password: { type: "apiKey", in: "header", name: "X-SAP-B1-Password" }
|
|
},
|
|
schemas: {
|
|
ODataResponse: {
|
|
type: "object",
|
|
properties: {
|
|
value: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
"odata.nextLink": { type: "string" },
|
|
"@odata.nextLink": { type: "string" }
|
|
}
|
|
},
|
|
SapB1Error: {
|
|
type: "object",
|
|
properties: {
|
|
error: {
|
|
type: "object",
|
|
properties: {
|
|
type: { type: "string" },
|
|
code: { type: "string" },
|
|
message: { type: "string" },
|
|
retryable: { type: "boolean" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function resolveBasePath(req: Request): string {
|
|
// Behind the AppFactory reverse proxy the public prefix is /apps/<app-id>, but the
|
|
// handle_path route strips it before the request reaches this container, so it cannot be
|
|
// read from the request path. ROOT_PATH is the sanctioned source; X-Forwarded-Prefix is a
|
|
// fallback for proxies that forward it. Empty when running without a proxy.
|
|
const forwardedPrefix = getHeader(req, "x-forwarded-prefix");
|
|
return (rootPath || forwardedPrefix || "").replace(/\/+$/, "");
|
|
}
|
|
|
|
function swaggerUiOptions() {
|
|
return {
|
|
customSiteTitle: "SAP Business One connector API",
|
|
swaggerOptions: {
|
|
// Relative endpoint (same approach as the sibling idoklad service): the browser resolves
|
|
// it against the docs page, i.e. {prefix}/docs/openapi.json, so the spec loads locally and
|
|
// behind the AppFactory proxy without hardcoding the /apps/<app-id> prefix.
|
|
url: "openapi.json",
|
|
displayRequestDuration: true,
|
|
persistAuthorization: true,
|
|
tryItOutEnabled: true,
|
|
filter: true,
|
|
tagsSorter: "alpha",
|
|
operationsSorter: "method"
|
|
}
|
|
};
|
|
}
|
|
|
|
app.get("/", (_req, res) => {
|
|
res.json(serviceMetadata());
|
|
});
|
|
|
|
app.get("/health", (_req, res) => {
|
|
res.json({ status: "ok" });
|
|
});
|
|
|
|
// OpenAPI document served under the same /docs prefix as the UI so the relative UI endpoint
|
|
// resolves correctly locally and behind the reverse-proxy prefix. servers[0].url advertises the
|
|
// public prefix (ROOT_PATH) so Swagger UI "Try it out" targets {prefix}/api/..., not the host root.
|
|
app.get("/docs/openapi.json", (req, res) => {
|
|
res.json(openApiDocument(resolveBasePath(req)));
|
|
});
|
|
|
|
// Documented root alias for direct access to the OpenAPI document.
|
|
app.get("/openapi.json", (req, res) => {
|
|
res.json(openApiDocument(resolveBasePath(req)));
|
|
});
|
|
|
|
app.use("/docs", swaggerUi.serve, swaggerUi.setup(undefined, swaggerUiOptions()));
|
|
|
|
addResourceEndpoints();
|
|
|
|
if (require.main === module) {
|
|
app.listen(port, "0.0.0.0", () => {
|
|
console.log(serviceId + " listening on port " + port);
|
|
});
|
|
}
|
|
|
|
export { app };
|