@smartsoft001/crud-shell-nestjs

Turns CrudService into an endpoint: one module call gives a collection its REST routes, its JWT guards and, optionally, a websocket feed of its changes.


Install

npm install @smartsoft001/crud-shell-nestjs @smartsoft001/crud-domain @smartsoft001/crud-shell-app-services @smartsoft001/crud-shell-dtos @smartsoft001/domain-core @smartsoft001/mongo @smartsoft001/nestjs @smartsoft001/users @smartsoft001/utils

The manifest declares all eight workspace packages above as peer dependencies, pinned to its own version, so a package manager warns when one of them is missing rather than letting the failure appear at import time. Alongside crud-shell-app-services, crud-shell-dtos and domain-core that covers @smartsoft001/mongo, @smartsoft001/nestjs, @smartsoft001/users, @smartsoft001/utils and @smartsoft001/crud-domain, which the controller names in the signature of its create route.

On top of the workspace packages the controller and the gateway need @nestjs/common, @nestjs/jwt, @nestjs/passport, @nestjs/websockets with socket.io, express, busboy for multipart uploads, json2csv and xlsx for the two export formats, plus lodash and moment-timezone.

What it is

Everything a collection needs over HTTP is identical from one collection to the next, so this package writes it once and parameterises it. CrudShellNestjsModule.forRoot returns a dynamic module carrying the CRUD service, the Mongo repositories behind it, the guards and, depending on two flags, the controller and the gateway. An application imports it once per collection, under a route prefix of its choosing, and gets ten routes it did not write.

The controller is deliberately thin. It parses, it shapes the response, and it delegates; the rules stay in @smartsoft001/crud-shell-app-services. What it does add is the parts that only make sense at the edge: query parsing for the list route, CSV and XLSX rendering, Location headers, HTTP range support for attachment downloads and a multipart parser for uploads.

Nothing connects eagerly. MongoModule.forRoot registers providers whose client opens on first use, so the module compiles in a test without a database. What is constructed eagerly is the JWT strategy behind the guards, which needs a non-empty signing key.

Usage

Register a collection

import { Module } from '@nestjs/common';

import { CrudShellNestjsModule } from '@smartsoft001/crud-shell-nestjs';

import { Note } from './crud-service.example';

@Module({
  imports: [
    CrudShellNestjsModule.forRoot({
      // The JWT secret guards the REST routes. It must not be empty when
      // `restApi` is on, because the passport strategy is built eagerly.
      tokenConfig: {
        secretOrPrivateKey: 'change-me',
        expiredIn: 3600,
      },
      permissions: {
        create: ['admin'],
        read: ['admin', 'user'],
        update: ['admin'],
        delete: ['admin'],
      },
      // The connection is opened lazily, on the first query.
      db: {
        host: 'localhost',
        port: 27017,
        database: 'my-app',
        collection: 'notes',
        // The model class. `CrudService` turns every request body into an
        // instance of it before validating, so without it a body missing a
        // required field would be stored as sent.
        type: Note,
      },
      restApi: true,
      socket: false,
    }),
  ],
})
export class NotesModule {}

The region imports the dynamic module into a feature module. The options object is the shared configuration from @smartsoft001/nestjs, which is the signing key and the roles allowed per operation, plus the database settings and the two flags. db.type names the Note model from the service example, which is what the service validates request bodies against. restApi: true registers the controller, socket: false keeps the websocket gateway and its socket.io dependency out.

Its spec compiles the module with Test.createTestingModule and resolves three tokens: CrudService, which proves the service providers are registered, CrudController, which proves the restApi flag reached the controller list, and IItemRepository, which comes back as a MongoItemRepository and proves the abstract contract is bound to the Mongo implementation. The whole spec runs offline, with no database and no network.

What the create route does

import { Response } from 'express';

import { CrudController } from '@smartsoft001/crud-shell-nestjs';
import { IUser } from '@smartsoft001/users';

import { Note } from './crud-service.example';

/**
 * What `POST /` of the CRUD controller does. The route is registered by
 * `CrudShellNestjsModule.forRoot({ restApi: true })` and guarded by
 * `AuthJwtGuard`, so `user` comes from the JWT.
 *
 * Nest hands the handler the raw response, which is why the controller writes
 * the header itself: `CrudController.getLink(res.req)` rebuilds the request
 * URL from the protocol, the Host header and the path, and the new id is
 * appended to it. The body is `{ id }` with status 200.
 */
export async function postNote(
  controller: CrudController<Note>,
  note: Note,
  user: IUser,
  res: Response,
): Promise<string | undefined> {
  await controller.create(note, user, res);

  return res.get('Location');
}

The region calls the controller's create handler the way Nest would, with a body, a user and the raw response object, and reads back the header the handler wrote. It stands in for the route rather than replacing it: in an application the guard fills user from the JWT and Nest supplies the response.

The spec drives it against a stubbed CrudService and a fake response whose request reports the https protocol, a Host of api.example.com and a URL of /notes. It asserts the Location header comes back as https://api.example.com/notes/id-1, so getLink rebuilds the request URL and appends the new id; that the body is { id: 'id-1' }; and that the payload and the caller reached the service unchanged.

API

CrudShellNestjsModule.forRoot(options)

The options are SharedConfig from @smartsoft001/nestjs intersected with the database settings and the two flags.

OptionTypeWhat it does
tokenConfig{ secretOrPrivateKey: string; expiredIn: number }The key the guards verify bearer tokens with, and the lifetime the registered JwtModule signs with.
permissionsISharedPermissionsRole names allowed to create, read, update and delete. What PermissionService checks on every call.
db.host, db.port, db.databasestring, number, stringPassed straight to MongoModule.forRoot. The connection opens on the first query.
db.username, db.passwordstringOptional credentials for the connection.
db.collectionstringThe collection this module instance serves.
db.typeTThe @Model class of the collection. The repository reads its search fields, and the module copies it into SharedConfig.type, which is what CrudService turns every request body into before validating it.
typeanyThe same class as a SharedConfig field. When both are given this one wins.
restApibooleanRegisters CrudController when true, and no controllers at all when false.
socketbooleanRegisters CrudGateway when true. Leave it false to keep socket.io out of the process.

Set the model type

A request body is a plain object with no field metadata. CrudService validates it only after turning it into an instance of the configured class, so a module registered without db.type or type stores whatever it is sent, required fields or not, and logs a warning at startup. The model example above sets db.type for that reason.

The module provides the CRUD service and AuthJwtGuard, and imports SharedModule.forFeature(options) with type filled from db.type, and MongoModule.forRoot(options.db). It exports the service, the guard and the Mongo module, so an importing module can inject the repositories too. Passport and JwtModule are only registered when restApi is true and tokenConfig.secretOrPrivateKey is set; the strategy itself comes from SharedModule and is constructed eagerly, so an empty key fails at startup rather than on the first request.

CrudShellNestjsCoreModule.forRoot(options)

The same options without restApi and socket. It never registers controllers, always registers the gateway and the guard, always registers Passport and JwtModule, and imports SharedModule.forRoot(options) rather than forFeature, so it also carries the root configuration. The DynamicModule it returns sets module: CrudShellNestjsCoreModule, its own class, so the two variants are separate modules and an application can import either one.

The core module exports nothing

Its exports list is empty, which means an importing module sees none of its providers. Import CrudShellNestjsModule with restApi: false and socket: false when the importing module has to inject the CRUD service or the repositories.

CrudController

Declared as @Controller(''), so its routes sit directly under whatever prefix the importing module is mounted at. Every handler delegates to CrudService.

RouteGuardWhat it does
POST /AuthJwtGuardCreates one record. Answers 200 with { id } and a Location header pointing at the new record.
POST /bulk?mode=AuthJwtGuardCreates many. The mode query parameter becomes the ICreateManyOptions. Answers with the created array.
GET /:idAuthOrAnonymousJwtGuardOne record. Throws NotFoundException('Invalid id') when the repository returns nothing.
GET /AuthOrAnonymousJwtGuardThe list. Answers { data, totalCount, links }, or a CSV or XLSX body when the request carries the matching Content-Type.
PUT /:idAuthJwtGuardFull update. No body in the response.
PATCH /:idAuthJwtGuardPartial update. No body in the response.
DELETE /:idAuthJwtGuardRemoves the record. No body in the response.
POST /attachmentsnoneMultipart upload parsed with Busboy. Answers { id, fileName, contentType, length } and a Location header.
GET /attachments/:idnoneDownloads the file. With a Range header it answers 206 with Content-Range, otherwise 200, in both cases as an attachment.
DELETE /attachments/:idnoneRemoves the file.

The three attachment routes carry no guard at all, so anyone who can reach the prefix can upload, download and delete files unless the application adds its own protection.

CrudController.getLink(req) is a static helper that rebuilds the request URL from req.protocol, the Host header and req.url. The create route and the upload route use it to build their Location headers.

Two things about the list route are worth knowing before you rely on it. Export is selected by the request's Content-Type rather than by Accept: text/csv renders through json2csv, and the spreadsheet media type renders through xlsx, both with allowDiskUse turned on for the query. Neither branch returns, so after writing the export the handler continues into the JSON res.send at the end of the method.

Guards

GuardBehaviour
AuthJwtGuardRequires a valid token. Logs the passport info at warning level and throws UnauthorizedException when there is none.
AuthOrAnonymousJwtGuardNever throws. Returns the user when the token is valid and undefined otherwise, which is what lets reads be public.

Both extend AuthGuard('jwt') from @nestjs/passport and rely on the strategy registered by SharedModule. Only AuthJwtGuard is provided and exported by the module; AuthOrAnonymousJwtGuard is used directly by the controller.

CrudGateway

A @WebSocketGateway over the websocket transport, registered only when socket: true. It subscribes clients to the change feed: a client emits changes with { id? } and receives one changes event per change, taken from CrudService.changes(...) and shaped as the union in @smartsoft001/crud-shell-dtos. One subscription is kept per socket id, replaced when the same client subscribes again and unsubscribed on disconnect.

Its path and namespace are built from the URL_PREFIX environment variable when the class is loaded, as /${URL_PREFIX}/_socket and /${URL_PREFIX}. The variable is read at module load, so it has to be set before the process imports the package, and an unset variable produces the literal segment undefined in both strings.

The class is not exported under its own name. The package barrel re-exports ./lib/gateways, which exports only the GATEWAYS provider array, so application code reaches the gateway through the module flag rather than by importing the class.

Not part of the public API

q2m, the query-to-Mongo parser behind the list route, lives in the controller directory and is not exported. It is what turns ?title=plan&limit=25&sort=-title into criteria, options and the links object the list response carries, and it is covered by its own spec inside the package.