diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 9ed600e4..ca8ac665 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -4,6 +4,15 @@
## How can it be tested?
+## Impacted packages
+
+Check the packages that require a new publication or release:
+
+- [ ] @mnfst/manifest
+- [ ] @mnfst/types
+- [ ] @mnfst/admin
+- [ ] doc
+
## Check list before submitting
- [ ] I have performed a self-review of my code (no debugs, no commented code, good naming, etc.)
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index e60eae58..af077caf 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -20,6 +20,10 @@ jobs:
- name: Install dependencies
run: npm ci
working-directory: ./packages/core/manifest
+ # Enure that we are using the latest version of the types package (even if it is not published yet).
+ - name: Link local types
+ run: npm run link-local-types
+ working-directory: ./packages/core/manifest
- name: Build App
run: npm run build
working-directory: ./packages/core/manifest
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 703ef541..2dacf2b3 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -34,6 +34,7 @@
"metas",
"mnfst",
"nestjs",
+ "openapi",
"typeorm",
"uniqid"
],
diff --git a/packages/core/admin/package-lock.json b/packages/core/admin/package-lock.json
index 2ae35b62..afeca7db 100644
--- a/packages/core/admin/package-lock.json
+++ b/packages/core/admin/package-lock.json
@@ -18,7 +18,7 @@
"@angular/platform-browser-dynamic": "^17.3.1",
"@angular/router": "^17.3.1",
"@auth0/angular-jwt": "^5.2.0",
- "@mnfst/types": "^0.1.0-alpha.3",
+ "@mnfst/types": "^0.1.0-alpha.4",
"bulma": "^0.9.4",
"bulma-tooltip": "^3.0.2",
"rxjs": "~7.5.0",
@@ -2943,9 +2943,9 @@
}
},
"node_modules/@mnfst/types": {
- "version": "0.1.0-alpha.3",
- "resolved": "https://registry.npmjs.org/@mnfst/types/-/types-0.1.0-alpha.3.tgz",
- "integrity": "sha512-dz/G7m0JYD8VA4MI65F8L4K6nS12LxSyL0tJVsU723sQcPjKnFzKe+7J+OpxQTXVnnY494aWr+vXPLZV/LNwJQ=="
+ "version": "0.1.0-alpha.4",
+ "resolved": "https://registry.npmjs.org/@mnfst/types/-/types-0.1.0-alpha.4.tgz",
+ "integrity": "sha512-kn4aJtRRW8iG5yByKP+iUSl4T8NRC5EiLKK19pRA6hU9smqFz8TU7yVMUUja1ZmmX1xY3NkfDeFa7CK4CIMRfw=="
},
"node_modules/@ngtools/webpack": {
"version": "17.3.1",
diff --git a/packages/core/admin/package.json b/packages/core/admin/package.json
index 3aac9b7e..c0174a14 100644
--- a/packages/core/admin/package.json
+++ b/packages/core/admin/package.json
@@ -1,6 +1,6 @@
{
"name": "@mnfst/admin",
- "version": "0.1.0-alpha.4",
+ "version": "0.1.0-alpha.5",
"homepage": "https://manifest.build",
"keywords": [
"admin",
@@ -28,7 +28,8 @@
"start": "ng serve",
"build": "ng build --configuration production",
"watch": "ng build --watch --configuration development",
- "test": "ng test"
+ "test": "ng test",
+ "link-local-types": "cd ../types && npm install && npm run build && npm link && cd ../manifest && npm link @mnfst/types"
},
"private": false,
"license": "MIT",
@@ -42,7 +43,7 @@
"@angular/platform-browser-dynamic": "^17.3.1",
"@angular/router": "^17.3.1",
"@auth0/angular-jwt": "^5.2.0",
- "@mnfst/types": "^0.1.0-alpha.3",
+ "@mnfst/types": "^0.1.0-alpha.4",
"bulma": "^0.9.4",
"bulma-tooltip": "^3.0.2",
"rxjs": "~7.5.0",
diff --git a/packages/core/admin/src/app/modules/shared/inputs/input.component.ts b/packages/core/admin/src/app/modules/shared/inputs/input.component.ts
index 357ec003..7248bb65 100644
--- a/packages/core/admin/src/app/modules/shared/inputs/input.component.ts
+++ b/packages/core/admin/src/app/modules/shared/inputs/input.component.ts
@@ -21,6 +21,7 @@ import { SelectInputComponent } from './select-input/select-input.component'
import { TextInputComponent } from './text-input/text-input.component'
import { TextareaInputComponent } from './textarea-input/textarea-input.component'
import { UrlInputComponent } from './url-input/url-input.component'
+import { TimestampInputComponent } from './timestamp-input/timestamp-input.component'
@Component({
selector: 'app-input',
@@ -30,6 +31,7 @@ import { UrlInputComponent } from './url-input/url-input.component'
BooleanInputComponent,
CurrencyInputComponent,
DateInputComponent,
+ TimestampInputComponent,
EmailInputComponent,
UrlInputComponent,
MultiSelectInputComponent,
@@ -111,6 +113,14 @@ import { UrlInputComponent } from './url-input/url-input.component'
*ngIf="prop?.type === PropType.Date"
>
+
+
{
+ let component: TimestampInputComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TimestampInputComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(TimestampInputComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/packages/core/admin/src/app/modules/shared/inputs/timestamp-input/timestamp-input.component.ts b/packages/core/admin/src/app/modules/shared/inputs/timestamp-input/timestamp-input.component.ts
new file mode 100644
index 00000000..5c10bef0
--- /dev/null
+++ b/packages/core/admin/src/app/modules/shared/inputs/timestamp-input/timestamp-input.component.ts
@@ -0,0 +1,44 @@
+import { NgClass } from '@angular/common'
+import {
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ Output,
+ ViewChild
+} from '@angular/core'
+import { PropertyManifest } from '@mnfst/types'
+
+@Component({
+ selector: 'app-timestamp-input',
+ standalone: true,
+ imports: [NgClass],
+ template: `
+ `
+})
+export class TimestampInputComponent {
+ @Input() prop: PropertyManifest
+ @Input() value: string
+ @Input() isError: boolean
+
+ @Output() valueChanged: EventEmitter = new EventEmitter()
+
+ @ViewChild('input', { static: true }) input: ElementRef
+
+ ngOnInit(): void {
+ if (this.value !== undefined) {
+ this.input.nativeElement.value = this.value
+ }
+ }
+
+ onChange(event: any) {
+ this.valueChanged.emit(event.target.value)
+ }
+}
diff --git a/packages/core/admin/src/app/modules/shared/yields/date-yield/date-yield.component.ts b/packages/core/admin/src/app/modules/shared/yields/date-yield/date-yield.component.ts
index 3358b1c3..ee280a33 100644
--- a/packages/core/admin/src/app/modules/shared/yields/date-yield/date-yield.component.ts
+++ b/packages/core/admin/src/app/modules/shared/yields/date-yield/date-yield.component.ts
@@ -1,10 +1,10 @@
-import { CommonModule } from '@angular/common'
+import { DatePipe, NgIf } from '@angular/common'
import { Component, Input } from '@angular/core'
@Component({
selector: 'app-date-yield',
standalone: true,
- imports: [CommonModule],
+ imports: [DatePipe, NgIf],
template: `{{ value | date : 'MM/dd/yy' }}
- `,
styleUrls: ['./date-yield.component.scss']
diff --git a/packages/core/admin/src/app/modules/shared/yields/timestamp-yield/timestamp-yield.component.spec.ts b/packages/core/admin/src/app/modules/shared/yields/timestamp-yield/timestamp-yield.component.spec.ts
new file mode 100644
index 00000000..4b14dec9
--- /dev/null
+++ b/packages/core/admin/src/app/modules/shared/yields/timestamp-yield/timestamp-yield.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TimestampYieldComponent } from './timestamp-yield.component';
+
+describe('TimestampYieldComponent', () => {
+ let component: TimestampYieldComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TimestampYieldComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(TimestampYieldComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/packages/core/admin/src/app/modules/shared/yields/timestamp-yield/timestamp-yield.component.ts b/packages/core/admin/src/app/modules/shared/yields/timestamp-yield/timestamp-yield.component.ts
new file mode 100644
index 00000000..03809b65
--- /dev/null
+++ b/packages/core/admin/src/app/modules/shared/yields/timestamp-yield/timestamp-yield.component.ts
@@ -0,0 +1,14 @@
+import { DatePipe } from '@angular/common'
+import { Component, Input } from '@angular/core'
+
+@Component({
+ selector: 'app-timestamp-yield',
+ standalone: true,
+ imports: [DatePipe],
+ template: `{{
+ value | date : 'yyyy-MM-dd HH:mm:ss'
+ }}`
+})
+export class TimestampYieldComponent {
+ @Input() value: string
+}
diff --git a/packages/core/admin/src/app/modules/shared/yields/yield.component.ts b/packages/core/admin/src/app/modules/shared/yields/yield.component.ts
index 6ebf16a9..db3ce217 100644
--- a/packages/core/admin/src/app/modules/shared/yields/yield.component.ts
+++ b/packages/core/admin/src/app/modules/shared/yields/yield.component.ts
@@ -13,6 +13,7 @@ import { LocationYieldComponent } from './location-yield/location-yield.componen
import { NumberYieldComponent } from './number-yield/number-yield.component'
import { ProgressBarYieldComponent } from './progress-bar-yield/progress-bar-yield.component'
import { TextYieldComponent } from './text-yield/text-yield.component'
+import { TimestampYieldComponent } from './timestamp-yield/timestamp-yield.component'
@Component({
selector: 'app-yield',
@@ -22,6 +23,7 @@ import { TextYieldComponent } from './text-yield/text-yield.component'
BooleanYieldComponent,
CurrencyYieldComponent,
DateYieldComponent,
+ TimestampYieldComponent,
EmailYieldComponent,
NumberYieldComponent,
LinkYieldComponent,
@@ -45,22 +47,23 @@ import { TextYieldComponent } from './text-yield/text-yield.component'
[value]="value"
[compact]="compact"
>
-
-
-
+
{
// Store request object in global scope to use in tests.
global.request = supertest(app.getHttpServer())
+ // Set the SwaggerModule to serve the OpenAPI doc.
+ const openApiService = app.get(OpenApiService)
+ SwaggerModule.setup('api', app, openApiService.generateOpenApiObject())
+
await app.init()
})
diff --git a/packages/core/manifest/e2e/tests/crud.e2e-spec.ts b/packages/core/manifest/e2e/tests/crud.e2e-spec.ts
index 4a59d93f..1a4ab5a0 100644
--- a/packages/core/manifest/e2e/tests/crud.e2e-spec.ts
+++ b/packages/core/manifest/e2e/tests/crud.e2e-spec.ts
@@ -3,7 +3,16 @@ import { Paginator, SelectOption } from '@mnfst/types'
describe('CRUD (e2e)', () => {
const dummyDog = {
name: 'Fido',
- age: 5
+ age: 5,
+ website: 'https://example.com',
+ description: 'lorem ipsum',
+ birthdate: new Date().toISOString(),
+ password: 'password',
+ price: 100,
+ isGoodBoy: true,
+ acquiredAt: new Date().toISOString(),
+ email: 'test@example.com',
+ favoriteToy: 'ball'
}
it('POST /dynamic/:entity', async () => {
@@ -12,20 +21,50 @@ describe('CRUD (e2e)', () => {
expect(response.status).toBe(201)
})
- it('GET /dynamic/:entity', async () => {
- const response = await global.request.get('/dynamic/dogs')
+ describe('GET /dynamic/:entity', () => {
+ it('should return all items', async () => {
+ const response = await global.request.get('/dynamic/dogs')
+
+ expect(response.status).toBe(200)
+ expect(response.body).toMatchObject>({
+ data: expect.any(Array),
+ currentPage: expect.any(Number),
+ lastPage: expect.any(Number),
+ from: expect.any(Number),
+ to: expect.any(Number),
+ total: expect.any(Number),
+ perPage: expect.any(Number)
+ })
+ expect(response.body.data.length).toBe(1)
+ })
- expect(response.status).toBe(200)
- expect(response.body).toMatchObject>({
- data: expect.any(Array),
- currentPage: expect.any(Number),
- lastPage: expect.any(Number),
- from: expect.any(Number),
- to: expect.any(Number),
- total: expect.any(Number),
- perPage: expect.any(Number)
+ it('should filter items by field', async () => {
+ const response = await global.request.get(
+ `/dynamic/dogs?name_eq=${dummyDog.name}`
+ )
+
+ expect(response.status).toBe(200)
+ expect(response.body.data.length).toBe(1)
+ })
+
+ it('should filter items by relationship', async () => {
+ const bigNumber: number = 999
+
+ const response = await global.request.get(
+ `/dynamic/dogs?relations=owner&owner.id_eq=${bigNumber}`
+ )
+
+ expect(response.status).toBe(200)
+ expect(response.body.data.length).toBe(0)
+ })
+
+ it('should return a 400 error if the filter field does not exist', async () => {
+ const response = await global.request.get(
+ `/dynamic/dogs?invalidField_eq=${dummyDog.name}`
+ )
+
+ expect(response.status).toBe(400)
})
- expect(response.body.data.length).toBe(1)
})
it('GET /dynamic/:entity/select-options', async () => {
diff --git a/packages/core/manifest/e2e/tests/open-api.e2e-spec.ts b/packages/core/manifest/e2e/tests/open-api.e2e-spec.ts
new file mode 100644
index 00000000..4976b38c
--- /dev/null
+++ b/packages/core/manifest/e2e/tests/open-api.e2e-spec.ts
@@ -0,0 +1,8 @@
+describe('Open API (e2e)', () => {
+ it('GET /api', async () => {
+ const response = await global.request.get('/api')
+
+ expect(response.status).toBe(200)
+ expect(response.text).toContain('')
+ })
+})
diff --git a/packages/core/manifest/package-lock.json b/packages/core/manifest/package-lock.json
index d827a576..b31564d2 100644
--- a/packages/core/manifest/package-lock.json
+++ b/packages/core/manifest/package-lock.json
@@ -10,12 +10,13 @@
"license": "MIT",
"dependencies": {
"@faker-js/faker": "^8.4.1",
- "@mnfst/admin": "^0.1.0-alpha.4",
- "@mnfst/types": "^0.1.0-alpha.3",
+ "@mnfst/admin": "^0.1.0-alpha.5",
+ "@mnfst/types": "^0.1.0-alpha.4",
"@nestjs/common": "^10.3.3",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.3.3",
"@nestjs/platform-express": "^10.3.3",
+ "@nestjs/swagger": "^7.3.1",
"@nestjs/typeorm": "^10.0.2",
"ajv": "^8.12.0",
"better-sqlite3": "^9.6.0",
@@ -1819,10 +1820,15 @@
"node": ">=8"
}
},
+ "node_modules/@microsoft/tsdoc": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz",
+ "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug=="
+ },
"node_modules/@mnfst/admin": {
- "version": "0.1.0-alpha.4",
- "resolved": "https://registry.npmjs.org/@mnfst/admin/-/admin-0.1.0-alpha.4.tgz",
- "integrity": "sha512-iA78+xb8raoBrdbmj65dDvkB8vIoKyGfDR0KxWjJOok1dLG0zlnxV+5T6a4BVYD5UhSqNtBu8tO2N9OZLX4HHA==",
+ "version": "0.1.0-alpha.5",
+ "resolved": "https://registry.npmjs.org/@mnfst/admin/-/admin-0.1.0-alpha.5.tgz",
+ "integrity": "sha512-TaUz4T2Kivn3LNXjoSSzO4ADoWUI7z18UW12HxhVJj2t7uz+cItGQeTKYywQCW68QtVmwdzQH8AZU3oyg/AvVw==",
"dependencies": {
"@angular/animations": "^17.3.1",
"@angular/common": "^17.3.1",
@@ -1833,7 +1839,7 @@
"@angular/platform-browser-dynamic": "^17.3.1",
"@angular/router": "^17.3.1",
"@auth0/angular-jwt": "^5.2.0",
- "@mnfst/types": "^0.1.0-alpha.3",
+ "@mnfst/types": "^0.1.0-alpha.4",
"bulma": "^0.9.4",
"bulma-tooltip": "^3.0.2",
"rxjs": "~7.5.0",
@@ -1850,9 +1856,9 @@
}
},
"node_modules/@mnfst/types": {
- "version": "0.1.0-alpha.3",
- "resolved": "https://registry.npmjs.org/@mnfst/types/-/types-0.1.0-alpha.3.tgz",
- "integrity": "sha512-dz/G7m0JYD8VA4MI65F8L4K6nS12LxSyL0tJVsU723sQcPjKnFzKe+7J+OpxQTXVnnY494aWr+vXPLZV/LNwJQ=="
+ "version": "0.1.0-alpha.4",
+ "resolved": "https://registry.npmjs.org/@mnfst/types/-/types-0.1.0-alpha.4.tgz",
+ "integrity": "sha512-kn4aJtRRW8iG5yByKP+iUSl4T8NRC5EiLKK19pRA6hU9smqFz8TU7yVMUUja1ZmmX1xY3NkfDeFa7CK4CIMRfw=="
},
"node_modules/@nestjs/cli": {
"version": "10.3.2",
@@ -2040,6 +2046,25 @@
}
}
},
+ "node_modules/@nestjs/mapped-types": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz",
+ "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==",
+ "peerDependencies": {
+ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
+ "class-transformer": "^0.4.0 || ^0.5.0",
+ "class-validator": "^0.13.0 || ^0.14.0",
+ "reflect-metadata": "^0.1.12 || ^0.2.0"
+ },
+ "peerDependenciesMeta": {
+ "class-transformer": {
+ "optional": true
+ },
+ "class-validator": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@nestjs/platform-express": {
"version": "10.3.8",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.8.tgz",
@@ -2082,6 +2107,38 @@
"integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==",
"dev": true
},
+ "node_modules/@nestjs/swagger": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.3.1.tgz",
+ "integrity": "sha512-LUC4mr+5oAleEC/a2j8pNRh1S5xhKXJ1Gal5ZdRjt9XebQgbngXCdW7JTA9WOEcwGtFZN9EnKYdquzH971LZfw==",
+ "dependencies": {
+ "@microsoft/tsdoc": "^0.14.2",
+ "@nestjs/mapped-types": "2.0.5",
+ "js-yaml": "4.1.0",
+ "lodash": "4.17.21",
+ "path-to-regexp": "3.2.0",
+ "swagger-ui-dist": "5.11.2"
+ },
+ "peerDependencies": {
+ "@fastify/static": "^6.0.0 || ^7.0.0",
+ "@nestjs/common": "^9.0.0 || ^10.0.0",
+ "@nestjs/core": "^9.0.0 || ^10.0.0",
+ "class-transformer": "*",
+ "class-validator": "*",
+ "reflect-metadata": "^0.1.12 || ^0.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@fastify/static": {
+ "optional": true
+ },
+ "class-transformer": {
+ "optional": true
+ },
+ "class-validator": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@nestjs/testing": {
"version": "10.3.3",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.3.tgz",
@@ -10062,6 +10119,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/swagger-ui-dist": {
+ "version": "5.11.2",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.11.2.tgz",
+ "integrity": "sha512-jQG0cRgJNMZ7aCoiFofnoojeSaa/+KgWaDlfgs8QN+BXoGMpxeMVY5OEnjq4OlNvF3yjftO8c9GRAgcHlO+u7A=="
+ },
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
diff --git a/packages/core/manifest/package.json b/packages/core/manifest/package.json
index 4f153f6b..a567ddbe 100644
--- a/packages/core/manifest/package.json
+++ b/packages/core/manifest/package.json
@@ -1,6 +1,6 @@
{
"name": "manifest",
- "version": "4.0.0-alpha.8",
+ "version": "4.0.0-alpha.9",
"description": "Effortless backends",
"author": "Manifest",
"license": "MIT",
@@ -37,6 +37,7 @@
"test:e2e": "jest --config ./e2e/jest-e2e.json",
"test:e2e:ci": "jest --config ./e2e/jest-e2e.json --ci",
"generate-types": "ts-node src/manifest/json-schema/GenerateTypes.ts",
+ "link-local-types": "cd ../types && npm install && npm run build && npm link && cd ../manifest && npm link @mnfst/types",
"prebuild": "npm run generate-types"
},
"files": [
@@ -46,12 +47,13 @@
],
"dependencies": {
"@faker-js/faker": "^8.4.1",
- "@mnfst/admin": "^0.1.0-alpha.4",
- "@mnfst/types": "^0.1.0-alpha.3",
+ "@mnfst/admin": "^0.1.0-alpha.5",
+ "@mnfst/types": "^0.1.0-alpha.4",
"@nestjs/common": "^10.3.3",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.3.3",
"@nestjs/platform-express": "^10.3.3",
+ "@nestjs/swagger": "^7.3.1",
"@nestjs/typeorm": "^10.0.2",
"ajv": "^8.12.0",
"better-sqlite3": "^9.6.0",
diff --git a/packages/core/manifest/src/app.module.ts b/packages/core/manifest/src/app.module.ts
index 7f15e8ac..98c9c77c 100644
--- a/packages/core/manifest/src/app.module.ts
+++ b/packages/core/manifest/src/app.module.ts
@@ -16,6 +16,7 @@ import { ManifestModule } from './manifest/manifest.module'
import { SeedModule } from './seed/seed.module'
import { HealthModule } from './health/health.module'
import { BetterSqlite3ConnectionOptions } from 'typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions'
+import { OpenApiModule } from './open-api/open-api.module'
@Module({
imports: [
@@ -44,7 +45,8 @@ import { BetterSqlite3ConnectionOptions } from 'typeorm/driver/better-sqlite3/Be
CrudModule,
AuthModule,
LoggerModule,
- HealthModule
+ HealthModule,
+ OpenApiModule
]
})
export class AppModule {
@@ -57,8 +59,9 @@ export class AppModule {
private async init() {
const isSeed: boolean = process.argv[1].includes('seed')
const isTest: boolean = process.env.NODE_ENV === 'test'
+ const isProduction: boolean = process.env.NODE_ENV === 'production'
- if (!isSeed && !isTest) {
+ if (!isSeed && !isTest && !isProduction) {
this.loggerService.initMessage()
}
}
diff --git a/packages/core/manifest/src/auth/auth.controller.ts b/packages/core/manifest/src/auth/auth.controller.ts
index 9df304f6..ba354894 100644
--- a/packages/core/manifest/src/auth/auth.controller.ts
+++ b/packages/core/manifest/src/auth/auth.controller.ts
@@ -4,7 +4,9 @@ import { AuthenticableEntity } from '@mnfst/types'
import { Request } from 'express'
import { AuthService } from './auth.service'
import { SignupAuthenticableEntityDto } from './dtos/signup-authenticable-entity.dto'
+import { ApiTags } from '@nestjs/swagger'
+@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
diff --git a/packages/core/manifest/src/auth/dtos/signup-authenticable-entity.dto.ts b/packages/core/manifest/src/auth/dtos/signup-authenticable-entity.dto.ts
index 5524ddb2..491b10cd 100644
--- a/packages/core/manifest/src/auth/dtos/signup-authenticable-entity.dto.ts
+++ b/packages/core/manifest/src/auth/dtos/signup-authenticable-entity.dto.ts
@@ -1,10 +1,19 @@
+import { ApiProperty } from '@nestjs/swagger'
import { IsEmail, IsNotEmpty } from 'class-validator'
export class SignupAuthenticableEntityDto {
+ @ApiProperty({
+ description: 'The email of the user',
+ example: 'admin@manifest.build'
+ })
@IsNotEmpty()
@IsEmail()
public email: string
+ @ApiProperty({
+ description: 'The password of the user',
+ example: 'admin'
+ })
@IsNotEmpty()
public password: string
}
diff --git a/packages/core/manifest/src/config/paths.ts b/packages/core/manifest/src/config/paths.ts
index 6ba5480d..73e702f8 100644
--- a/packages/core/manifest/src/config/paths.ts
+++ b/packages/core/manifest/src/config/paths.ts
@@ -1,7 +1,8 @@
-export default (): { paths: { admin: string } } => {
+export default (): { paths: { admin: string; database: string } } => {
return {
paths: {
- admin: `${process.cwd()}/node_modules/@mnfst/admin/dist`
+ admin: `${process.cwd()}/node_modules/@mnfst/admin/dist`,
+ database: `${process.cwd()}/manifest/backend.yml`
}
}
}
diff --git a/packages/core/manifest/src/constants.ts b/packages/core/manifest/src/constants.ts
index 9b62e682..587175a1 100644
--- a/packages/core/manifest/src/constants.ts
+++ b/packages/core/manifest/src/constants.ts
@@ -1,7 +1,3 @@
-// Manifest.
-export const MANIFEST_FOLDER_NAME = 'manifest'
-export const MANIFEST_FILE_NAME = 'backend.yml'
-
// Default values.
export const DEFAULT_PORT = 1111
export const DEFAULT_RESULTS_PER_PAGE = 20
diff --git a/packages/core/manifest/src/crud/services/crud.service.ts b/packages/core/manifest/src/crud/services/crud.service.ts
index 3f7a420e..ae2c422e 100644
--- a/packages/core/manifest/src/crud/services/crud.service.ts
+++ b/packages/core/manifest/src/crud/services/crud.service.ts
@@ -77,13 +77,6 @@ export class CrudService {
let query: SelectQueryBuilder =
entityRepository.createQueryBuilder('entity')
- // Apply filters.
- this.filterQuery({
- query,
- queryParams,
- entityManifest
- })
-
// Select only visible props.
query.select(
this.getVisibleProps({
@@ -91,6 +84,21 @@ export class CrudService {
})
)
+ // Load relations.
+ this.loadRelations({
+ query,
+ entityMetadata,
+ belongsTo: entityManifest.belongsTo,
+ requestedRelations: queryParams?.relations?.toString().split(',')
+ })
+
+ // Apply filters.
+ this.filterQuery({
+ query,
+ queryParams,
+ entityManifest
+ })
+
// Apply ordering.
if (queryParams?.orderBy) {
if (
@@ -112,14 +120,6 @@ export class CrudService {
query.orderBy('entity.id', 'DESC')
}
- // Load relations.
- this.loadRelations({
- query,
- entityMetadata,
- belongsTo: entityManifest.belongsTo,
- requestedRelations: queryParams?.relations?.toString().split(',')
- })
-
// Paginate.
return this.paginationService.paginate({
query,
@@ -382,12 +382,14 @@ export class CrudService {
const suffix: WhereKeySuffix = Object.values(WhereKeySuffix)
.reverse()
.find((suffix) => key.includes(suffix))
+
if (!suffix) {
throw new HttpException(
'Query param key should include an operator suffix',
HttpStatus.BAD_REQUEST
)
}
+
const operator: WhereOperator = HelperService.getRecordKeyByValue(
whereOperatorKeySuffix,
suffix
@@ -398,27 +400,46 @@ export class CrudService {
(prop: PropertyManifest) => prop.name === propName && !prop.hidden
)
- if (!prop) {
+ const relation: RelationshipManifest = entityManifest.belongsTo.find(
+ (belongsTo: RelationshipManifest) =>
+ belongsTo.name === propName.split('.')[0]
+ )
+
+ if (!prop && !relation) {
throw new HttpException(
`Property ${propName} does not exist in ${entityManifest.className}`,
HttpStatus.BAD_REQUEST
)
}
+
+ let whereKey: string
+
+ if (relation) {
+ const aliasName: string = HelperService.camelCaseTwoStrings(
+ 'entity',
+ relation.name
+ )
+ whereKey = `${aliasName}.${propName.split('.')[1]}`
+ } else {
+ whereKey = `entity.${propName}`
+ }
+
// Allow "true" and "false" to be used for boolean props for convenience.
- if (prop.type === PropType.Boolean) {
+ if (prop && prop.type === PropType.Boolean) {
if (value === 'true') {
value = '1'
} else if (value === 'false') {
value = '0'
}
}
+
// Finally and the where query. "In" is a bit special as it expects an array of values.
if (operator === WhereOperator.In) {
- query.where(`entity.${propName} ${operator} (:...value)`, {
+ query.where(`${whereKey} ${operator} (:...value)`, {
value: JSON.parse(`[${value}]`)
})
} else {
- query.where(`entity.${propName} ${operator} :value`, {
+ query.where(`${whereKey} ${operator} :value`, {
value
})
}
diff --git a/packages/core/manifest/src/crud/tests/crud.service.spec.ts b/packages/core/manifest/src/crud/tests/crud.service.spec.ts
new file mode 100644
index 00000000..9c2edc51
--- /dev/null
+++ b/packages/core/manifest/src/crud/tests/crud.service.spec.ts
@@ -0,0 +1,41 @@
+import { CrudService } from '../services/crud.service'
+import { Test, TestingModule } from '@nestjs/testing'
+import { ManifestService } from '../../manifest/services/manifest/manifest.service'
+import { PaginationService } from '../services/pagination.service'
+import { EntityService } from '../../entity/services/entity/entity.service'
+
+describe('CrudService', () => {
+ let service: CrudService
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ CrudService,
+ {
+ provide: ManifestService,
+ useValue: {
+ getEntityRepository: jest.fn()
+ }
+ },
+ {
+ provide: PaginationService,
+ useValue: {
+ paginate: jest.fn()
+ }
+ },
+ {
+ provide: EntityService,
+ useValue: {
+ findOne: jest.fn()
+ }
+ }
+ ]
+ }).compile()
+
+ service = module.get(CrudService)
+ })
+
+ it('should be defined', () => {
+ expect(service).toBeDefined()
+ })
+})
diff --git a/packages/core/manifest/src/entity/records/prop-type-column-types.ts b/packages/core/manifest/src/entity/records/prop-type-column-types.ts
index abd28a31..76014131 100644
--- a/packages/core/manifest/src/entity/records/prop-type-column-types.ts
+++ b/packages/core/manifest/src/entity/records/prop-type-column-types.ts
@@ -9,6 +9,7 @@ export const propTypeColumnTypes: Record = {
[PropType.Text]: 'text',
[PropType.Money]: 'decimal',
[PropType.Date]: 'date',
+ [PropType.Timestamp]: 'text',
[PropType.Email]: 'varchar',
[PropType.Boolean]: 'boolean',
[PropType.Password]: 'varchar',
diff --git a/packages/core/manifest/src/entity/records/prop-type-seed-functions.ts b/packages/core/manifest/src/entity/records/prop-type-seed-functions.ts
index 3e3dc1a1..e57233f9 100644
--- a/packages/core/manifest/src/entity/records/prop-type-seed-functions.ts
+++ b/packages/core/manifest/src/entity/records/prop-type-seed-functions.ts
@@ -16,6 +16,7 @@ export const propTypeSeedFunctions: Record any> = {
dec: 2
}),
[PropType.Date]: () => faker.date.past(),
+ [PropType.Timestamp]: () => faker.date.recent(),
[PropType.Email]: () => faker.internet.email(),
[PropType.Boolean]: () => faker.datatype.boolean(),
[PropType.Password]: () => SHA3('manifest').toString(),
diff --git a/packages/core/manifest/src/entity/services/entity/entity.service.spec.ts b/packages/core/manifest/src/entity/services/entity/entity.service.spec.ts
index b72e8b64..70ce25a9 100644
--- a/packages/core/manifest/src/entity/services/entity/entity.service.spec.ts
+++ b/packages/core/manifest/src/entity/services/entity/entity.service.spec.ts
@@ -5,6 +5,7 @@ import { DataSource } from 'typeorm'
describe('EntityService', () => {
let service: EntityService
+ let dataSource: DataSource
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -27,9 +28,30 @@ describe('EntityService', () => {
}).compile()
service = module.get(EntityService)
+ dataSource = module.get(DataSource)
})
it('should be defined', () => {
expect(service).toBeDefined()
})
+
+ describe('getEntityRepository', () => {
+ it('should fail if no entity metadata or entity slug is provided', () => {
+ expect(() => {
+ service.getEntityRepository({})
+ }).toThrow()
+ })
+
+ it('should return a repository', () => {
+ const entityMetadata = {
+ target: 'Entity'
+ } as any
+
+ const result = service.getEntityRepository({
+ entityMetadata
+ })
+
+ expect(dataSource.getRepository).toHaveBeenCalledWith('Entity')
+ })
+ })
})
diff --git a/packages/core/manifest/src/logger/logger.service.ts b/packages/core/manifest/src/logger/logger.service.ts
index 6bbeb1dc..948f0f97 100644
--- a/packages/core/manifest/src/logger/logger.service.ts
+++ b/packages/core/manifest/src/logger/logger.service.ts
@@ -17,13 +17,21 @@ export class LoggerService {
console.log()
+ console.log(chalk.blue('Manifest backend successfully started! '))
+ console.log()
+
console.log(
chalk.blue(
- '🎉 Manifest successfully started! See your admin panel: ',
+ '🖥️ Admin Panel: ',
chalk.underline.blue(`http://localhost:${port}`)
)
)
-
+ console.log(
+ chalk.blue(
+ '📚 API Doc: ',
+ chalk.underline.blue(`http://localhost:${port}/api`)
+ )
+ )
console.log()
}
}
diff --git a/packages/core/manifest/src/main.ts b/packages/core/manifest/src/main.ts
index f72f0cdb..b5adf486 100644
--- a/packages/core/manifest/src/main.ts
+++ b/packages/core/manifest/src/main.ts
@@ -1,12 +1,14 @@
import { ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core'
+import { SwaggerModule } from '@nestjs/swagger'
import connectLiveReload from 'connect-livereload'
import * as express from 'express'
import * as livereload from 'livereload'
import { join } from 'path'
import { AppModule } from './app.module'
import { DEFAULT_PORT } from './constants'
+import { OpenApiService } from './open-api/services/open-api.service'
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
@@ -21,10 +23,11 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe())
// Live reload.
- const production: boolean = configService.get('NODE_ENV') === 'production'
+ const isProduction: boolean = configService.get('NODE_ENV') === 'production'
+ const isTest: boolean = configService.get('NODE_ENV') === 'test'
// Reload the browser when server files change.
- if (!production) {
+ if (!isProduction && !isTest) {
const liveReloadServer = livereload.createServer()
liveReloadServer.server.once('connection', () => {
setTimeout(() => {
@@ -47,6 +50,1710 @@ async function bootstrap() {
}
})
+ if (!isProduction) {
+ const openApiService: OpenApiService = app.get(OpenApiService)
+
+ SwaggerModule.setup('api', app, openApiService.generateOpenApiObject(), {
+ customfavIcon: 'assets/images/favicon.png',
+ customSiteTitle: 'Manifest API Doc',
+ customCss: `
+
+.swagger-ui html {
+ box-sizing: border-box
+}
+
+.swagger-ui *, .swagger-ui :after, .swagger-ui :before {
+ box-sizing: inherit
+}
+
+.swagger-ui body {
+ margin: 0;
+ background: #F6F7F9
+}
+
+.swagger-ui .wrapper {
+ width: 100%;
+ max-width: 1460px;
+ margin: 0 auto;
+ padding: 0 20px
+}
+
+.swagger-ui .opblock-tag-section {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-orient: vertical;
+ -webkit-box-direction: normal;
+ -ms-flex-direction: column;
+ flex-direction: column
+}
+
+.swagger-ui .opblock-tag {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ padding: 10px 20px 10px 10px;
+ cursor: pointer;
+ -webkit-transition: all .2s;
+ transition: all .2s;
+ border-bottom: 1px solid #EAEAEF;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.swagger-ui .opblock-tag:hover {
+ background: rgba(57, 57, 60, .02)
+}
+
+.swagger-ui .opblock-tag {
+ font-size: 24px;
+ margin: 0 0 5px;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .opblock-tag.no-desc span {
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1
+}
+
+.swagger-ui .opblock-tag svg {
+ -webkit-transition: all .4s;
+ transition: all .4s
+}
+
+.swagger-ui .opblock-tag small {
+ font-size: 14px;
+ font-weight: 400;
+ padding: 0 10px;
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .parаmeter__type {
+ font-size: 12px;
+ padding: 5px 0;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151
+}
+
+.swagger-ui .view-line-link {
+ position: relative;
+ top: 3px;
+ width: 20px;
+ margin: 0 5px;
+ cursor: pointer;
+ -webkit-transition: all .5s;
+ transition: all .5s
+}
+
+.swagger-ui .opblock {
+ margin: 0 0 15px;
+ border: 1px solid #000;
+ border-radius: 4px;
+ box-shadow: none;
+}
+
+.swagger-ui .opblock.is-open .opblock-summary {
+ border-bottom: 1px solid #000
+}
+
+.swagger-ui .opblock .opblock-section-header {
+ padding: 8px 20px;
+ background: hsla(0, 0%, 100%, .8);
+ box-shadow: none;
+}
+
+.swagger-ui .opblock .opblock-section-header, .swagger-ui .opblock .opblock-section-header label {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.swagger-ui .opblock .opblock-section-header label {
+ font-size: 12px;
+ font-weight: 700;
+ margin: 0;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .opblock .opblock-section-header label span {
+ padding: 0 10px 0 0
+}
+
+.swagger-ui .opblock .opblock-section-header h4 {
+ font-size: 14px;
+ margin: 0;
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .opblock .opblock-summary-method {
+ font-size: 14px;
+ font-weight: 700;
+ min-width: 80px;
+ padding: 6px 15px;
+ text-align: center;
+ border-radius: 3px;
+ background: #000;
+ text-shadow: 0 1px 0 rgba(0, 0, 0, .1);
+ font-family: Titillium Web, sans-serif;
+ color: #fff
+}
+
+.swagger-ui .opblock .opblock-summary-path, .swagger-ui .opblock .opblock-summary-path__deprecated {
+ font-size: 16px;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ padding: 0 10px;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.swagger-ui .opblock .opblock-summary-path .view-line-link, .swagger-ui .opblock .opblock-summary-path__deprecated .view-line-link {
+ position: relative;
+ top: 2px;
+ width: 0;
+ margin: 0;
+ cursor: pointer;
+ -webkit-transition: all .5s;
+ transition: all .5s
+}
+
+.swagger-ui .opblock .opblock-summary-path:hover .view-line-link, .swagger-ui .opblock .opblock-summary-path__deprecated:hover .view-line-link {
+ width: 18px;
+ margin: 0 5px
+}
+
+.swagger-ui .opblock .opblock-summary-path__deprecated {
+ text-decoration: line-through
+}
+
+.swagger-ui .opblock .opblock-summary-description {
+ font-size: 13px;
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .opblock .opblock-summary {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ padding: 5px;
+ cursor: pointer;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.swagger-ui .opblock.opblock-post {
+ border-color: #57b3a0;
+ background: rgba(73, 204, 144, .1)
+}
+
+.swagger-ui .opblock.opblock-post .opblock-summary-method {
+ background: #57b3a0
+}
+
+.swagger-ui .opblock.opblock-post .opblock-summary {
+ border-color: #57b3a0
+}
+
+.swagger-ui .opblock.opblock-put {
+ border-color: #f8a94a;
+ background: rgba(252, 161, 48, .1)
+}
+
+.swagger-ui .opblock.opblock-put .opblock-summary-method {
+ background: #f8a94a
+}
+
+.swagger-ui .opblock.opblock-put .opblock-summary {
+ border-color: #f8a94a
+}
+
+.swagger-ui .opblock.opblock-delete {
+ border-color: #f46470;
+ background: rgba(249, 62, 62, .1)
+}
+
+.swagger-ui .opblock.opblock-delete .opblock-summary-method {
+ background: #f46470
+}
+
+.swagger-ui .opblock.opblock-delete .opblock-summary {
+ border-color: #f46470
+}
+
+.swagger-ui .opblock.opblock-get {
+ border-color: #2430F0;
+ background: #F5F7F9
+}
+
+.swagger-ui .opblock.opblock-get .opblock-summary-method {
+ background: #2430F0
+}
+
+.swagger-ui .opblock.opblock-get .opblock-summary {
+ border-color: #2430F0
+}
+
+.swagger-ui .opblock.opblock-patch {
+ border-color: #50e3c2;
+ background: rgba(80, 227, 194, .1)
+}
+
+.swagger-ui .opblock.opblock-patch .opblock-summary-method {
+ background: #50e3c2
+}
+
+.swagger-ui .opblock.opblock-patch .opblock-summary {
+ border-color: #50e3c2
+}
+ .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span:after {
+ background: #f8a94a;
+ }
+
+ .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span:after {
+ background: #57b3a0;
+ }
+
+.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span:after {
+background: #f46470;
+}
+
+
+.swagger-ui .opblock.opblock-head {
+ border-color: #9012fe;
+ background: rgba(144, 18, 254, .1)
+}
+
+.swagger-ui .opblock.opblock-head .opblock-summary-method {
+ background: #9012fe
+}
+
+.swagger-ui .opblock.opblock-head .opblock-summary {
+ border-color: #9012fe
+}
+
+.swagger-ui .opblock.opblock-options {
+ border-color: #0d5aa7;
+ background: rgba(13, 90, 167, .1)
+}
+
+.swagger-ui .opblock.opblock-options .opblock-summary-method {
+ background: #0d5aa7
+}
+
+.swagger-ui .opblock.opblock-options .opblock-summary {
+ border-color: #0d5aa7
+}
+
+.swagger-ui .opblock.opblock-deprecated {
+ opacity: .6;
+ border-color: #ebebeb;
+ background: hsla(0, 0%, 92%, .1)
+}
+
+.swagger-ui .opblock.opblock-deprecated .opblock-summary-method {
+ background: #ebebeb
+}
+
+.swagger-ui .opblock.opblock-deprecated .opblock-summary {
+ border-color: #ebebeb
+}
+
+.swagger-ui .tab {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ margin: 20px 0 10px;
+ padding: 0;
+ list-style: none
+}
+
+.swagger-ui .tab li {
+ font-size: 12px;
+ min-width: 100px;
+ min-width: 90px;
+ padding: 0;
+ cursor: pointer;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .tab li:first-of-type {
+ position: relative;
+ padding-left: 0
+}
+
+.swagger-ui .tab li:first-of-type:after {
+ position: absolute;
+ top: 0;
+ right: 6px;
+ width: 1px;
+ height: 100%;
+ content: "";
+ background: rgba(0, 0, 0, .2)
+}
+
+.swagger-ui .tab li.active {
+ font-weight: 700
+}
+
+.swagger-ui .opblock-description-wrapper, .swagger-ui .opblock-title_normal {
+ padding: 15px 20px
+}
+
+.swagger-ui .opblock-description-wrapper, .swagger-ui .opblock-description-wrapper h4, .swagger-ui .opblock-title_normal, .swagger-ui .opblock-title_normal h4 {
+ font-size: 12px;
+ margin: 0 0 5px;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .opblock-description-wrapper p, .swagger-ui .opblock-title_normal p {
+ font-size: 14px;
+ margin: 0;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .execute-wrapper {
+ padding: 20px;
+ text-align: right
+}
+
+.swagger-ui .execute-wrapper .btn {
+ width: 100%;
+ padding: 8px 40px
+}
+
+.swagger-ui .body-param-options {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-orient: vertical;
+ -webkit-box-direction: normal;
+ -ms-flex-direction: column;
+ flex-direction: column
+}
+
+.swagger-ui .body-param-options .body-param-edit {
+ padding: 10px 0
+}
+
+.swagger-ui .body-param-options label {
+ padding: 8px 0
+}
+
+.swagger-ui .body-param-options label select {
+ margin: 3px 0 0
+}
+
+.swagger-ui .responses-inner {
+ padding: 20px
+}
+
+.swagger-ui .responses-inner h4, .swagger-ui .responses-inner h5 {
+ font-size: 12px;
+ margin: 10px 0 5px;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .response-col_status {
+ font-size: 14px;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .response-col_status .response-undocumented {
+ font-size: 11px;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #999
+}
+
+.swagger-ui .response-col_description__inner span {
+ font-size: 12px;
+ font-style: italic;
+ display: block;
+ margin: 10px 0;
+ padding: 10px;
+ border-radius: 4px;
+ background: #41444e;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #fff
+}
+
+.swagger-ui .response-col_description__inner span p {
+ margin: 0
+}
+
+.swagger-ui .opblock-body pre {
+ font-size: 12px;
+ margin: 0;
+ padding: 10px;
+ white-space: pre-wrap;
+ border-radius: 4px;
+ background: #41444e;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #fff
+}
+
+.swagger-ui .opblock-body pre span {
+ color: #fff!important
+}
+
+.swagger-ui .scheme-container {
+ margin: 0 0 20px;
+ padding: 30px 0;
+ background: #fff;
+ box-shadow: none;
+}
+
+.swagger-ui .scheme-container .schemes {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.swagger-ui .scheme-container .schemes>label {
+ font-size: 12px;
+ font-weight: 700;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-orient: vertical;
+ -webkit-box-direction: normal;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ margin: -20px 15px 0 0;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .scheme-container .schemes>label select {
+ min-width: 130px;
+ text-transform: uppercase
+}
+
+.swagger-ui .loading-container {
+ padding: 40px 0 60px
+}
+
+.swagger-ui .loading-container .loading {
+ position: relative
+}
+
+.swagger-ui .loading-container .loading:after {
+ font-size: 10px;
+ font-weight: 700;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ content: "loading";
+ -webkit-transform: translate(-50%, -50%);
+ transform: translate(-50%, -50%);
+ text-transform: uppercase;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .loading-container .loading:before {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ display: block;
+ width: 60px;
+ height: 60px;
+ margin: -30px;
+ content: "";
+ -webkit-animation: rotation 1s infinite linear, opacity .5s;
+ animation: rotation 1s infinite linear, opacity .5s;
+ opacity: 1;
+ border: 2px solid rgba(85, 85, 85, .1);
+ border-top-color: rgba(0, 0, 0, .6);
+ border-radius: 100%;
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden
+}
+
+@-webkit-keyframes rotation {
+ to {
+ -webkit-transform: rotate(1turn);
+ transform: rotate(1turn)
+ }
+}
+
+@keyframes rotation {
+ to {
+ -webkit-transform: rotate(1turn);
+ transform: rotate(1turn)
+ }
+}
+
+@-webkit-keyframes blinker {
+ 50% {
+ opacity: 0
+ }
+}
+
+@keyframes blinker {
+ 50% {
+ opacity: 0
+ }
+}
+
+.swagger-ui .btn {
+ font-size: 14px;
+ font-weight: 700;
+ padding: 5px 23px;
+ -webkit-transition: all .3s;
+ transition: all .3s;
+ border: 2px solid #888;
+ border-radius: 4px;
+ background: transparent;
+ box-shadow: none;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .btn[disabled] {
+ cursor: not-allowed;
+ opacity: .3
+}
+
+.swagger-ui .btn:hover {
+ box-shadow: none;
+}
+
+.swagger-ui .btn.cancel {
+ border-color: #f46470;
+ font-family: Titillium Web, sans-serif;
+ color: #f46470
+}
+
+.swagger-ui .btn.authorize {
+ line-height: 1;
+ display: inline;
+ color: #57b3a0;
+ border-color: #57b3a0
+}
+
+.swagger-ui .btn.authorize span {
+ float: left;
+ padding: 4px 20px 0 0
+}
+
+.swagger-ui .btn.authorize svg {
+ fill: #57b3a0
+}
+
+.swagger-ui .btn.execute {
+ -webkit-animation: pulse 2s infinite;
+ animation: pulse 2s infinite;
+ color: #fff;
+ border-color: #2430F0
+}
+
+@-webkit-keyframes pulse {
+ 0% {
+ color: #fff;
+ background: #2430F0;
+ box-shadow: none;
+ }
+ 70% {
+ box-shadow: none;
+ }
+ to {
+ color: #fff;
+ background: #2430F0;
+ box-shadow: none;
+ }
+}
+
+@keyframes pulse {
+ 0% {
+ color: #fff;
+ background: #2430F0;
+ box-shadow: none;
+ }
+ 70% {
+ box-shadow: none;
+ }
+ to {
+ color: #fff;
+ background: #2430F0;
+ box-shadow: none;
+ }
+}
+
+.swagger-ui .btn-group {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ padding: 30px
+}
+
+.swagger-ui .btn-group .btn {
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1
+}
+
+.swagger-ui .btn-group .btn:first-child {
+ border-radius: 4px 0 0 4px
+}
+
+.swagger-ui .btn-group .btn:last-child {
+ border-radius: 0 4px 4px 0
+}
+
+.swagger-ui .authorization__btn {
+ padding: 0 10px;
+ border: none;
+ background: none
+}
+
+.swagger-ui .authorization__btn.locked {
+ opacity: 1
+}
+
+.swagger-ui .authorization__btn.unlocked {
+ opacity: .4
+}
+
+.swagger-ui .expand-methods, .swagger-ui .expand-operation {
+ border: none;
+ background: none
+}
+
+.swagger-ui .expand-methods svg, .swagger-ui .expand-operation svg {
+ width: 20px;
+ height: 20px
+}
+
+.swagger-ui .expand-methods {
+ padding: 0 10px
+}
+
+.swagger-ui .expand-methods:hover svg {
+ fill: #444
+}
+
+.swagger-ui .expand-methods svg {
+ -webkit-transition: all .3s;
+ transition: all .3s;
+ fill: #777
+}
+
+.swagger-ui button {
+ cursor: pointer;
+ outline: none
+}
+
+.swagger-ui select {
+ font-size: 14px;
+ font-weight: 700;
+ padding: 5px 40px 5px 10px;
+ border: 2px solid #41444e;
+ border-radius: 4px;
+ background: #f7f7f7 url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+ICAgIDxwYXRoIGQ9Ik0xMy40MTggNy44NTljLjI3MS0uMjY4LjcwOS0uMjY4Ljk3OCAwIC4yNy4yNjguMjcyLjcwMSAwIC45NjlsLTMuOTA4IDMuODNjLS4yNy4yNjgtLjcwNy4yNjgtLjk3OSAwbC0zLjkwOC0zLjgzYy0uMjctLjI2Ny0uMjctLjcwMSAwLS45NjkuMjcxLS4yNjguNzA5LS4yNjguOTc4IDBMMTAgMTFsMy40MTgtMy4xNDF6Ii8+PC9zdmc+) right 10px center no-repeat;
+ background-size: 20px;
+ box-shadow: none;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none
+}
+
+.swagger-ui select[multiple] {
+ margin: 5px 0;
+ padding: 5px;
+ background: #f7f7f7
+}
+
+.swagger-ui .opblock-body select {
+ min-width: 230px
+}
+
+.swagger-ui label {
+ font-size: 12px;
+ font-weight: 700;
+ margin: 0 0 5px;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui input[type=email], .swagger-ui input[type=password], .swagger-ui input[type=search], .swagger-ui input[type=text] {
+ min-width: 100px;
+ margin: 5px 0;
+ padding: 8px 10px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ background: #fff
+}
+
+.swagger-ui input[type=email].invalid, .swagger-ui input[type=password].invalid, .swagger-ui input[type=search].invalid, .swagger-ui input[type=text].invalid {
+ -webkit-animation: shake .4s 1;
+ animation: shake .4s 1;
+ border-color: #f46470;
+ background: #feebeb
+}
+
+@-webkit-keyframes shake {
+ 10%, 90% {
+ -webkit-transform: translate3d(-1px, 0, 0);
+ transform: translate3d(-1px, 0, 0)
+ }
+ 20%, 80% {
+ -webkit-transform: translate3d(2px, 0, 0);
+ transform: translate3d(2px, 0, 0)
+ }
+ 30%, 50%, 70% {
+ -webkit-transform: translate3d(-4px, 0, 0);
+ transform: translate3d(-4px, 0, 0)
+ }
+ 40%, 60% {
+ -webkit-transform: translate3d(4px, 0, 0);
+ transform: translate3d(4px, 0, 0)
+ }
+}
+
+@keyframes shake {
+ 10%, 90% {
+ -webkit-transform: translate3d(-1px, 0, 0);
+ transform: translate3d(-1px, 0, 0)
+ }
+ 20%, 80% {
+ -webkit-transform: translate3d(2px, 0, 0);
+ transform: translate3d(2px, 0, 0)
+ }
+ 30%, 50%, 70% {
+ -webkit-transform: translate3d(-4px, 0, 0);
+ transform: translate3d(-4px, 0, 0)
+ }
+ 40%, 60% {
+ -webkit-transform: translate3d(4px, 0, 0);
+ transform: translate3d(4px, 0, 0)
+ }
+}
+
+.swagger-ui textarea {
+ font-size: 12px;
+ width: 100%;
+ min-height: 280px;
+ padding: 10px;
+ border: none;
+ border-radius: 4px;
+ outline: none;
+ background: hsla(0, 0%, 100%, .8);
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151
+}
+
+.swagger-ui textarea:focus {
+ border: 2px solid #2430F0
+}
+
+.swagger-ui textarea.curl {
+ font-size: 12px;
+ min-height: 100px;
+ margin: 0;
+ padding: 10px;
+ resize: none;
+ border-radius: 4px;
+ background: #41444e;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #fff
+}
+ .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span:after {
+ background: #2430F0;
+ }
+
+
+ .swagger-ui .response-control-media-type__accept-message {
+ color: #57b3a0
+ }
+ .swagger-ui .response-control-media-type--accept-controller select {
+ border-color: #57b3a0}
+
+ .swagger-ui .checkbox {
+ padding: 5px 0 10px;
+ -webkit-transition: opacity .5s;
+ transition: opacity .5s;
+ color: #333
+}
+
+.swagger-ui .checkbox label {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex
+}
+
+.swagger-ui .checkbox p {
+ font-weight: 400!important;
+ font-style: italic;
+ margin: 0!important;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151
+}
+
+.swagger-ui .checkbox input[type=checkbox] {
+ display: none
+}
+
+.swagger-ui .checkbox input[type=checkbox]+label>.item {
+ position: relative;
+ top: 3px;
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin: 0 8px 0 0;
+ padding: 5px;
+ cursor: pointer;
+ border-radius: 1px;
+ background: #e8e8e8;
+ box-shadow: none;
+ -webkit-box-flex: 0;
+ -ms-flex: none;
+ flex: none
+}
+
+.swagger-ui .checkbox input[type=checkbox]+label>.item:active {
+ -webkit-transform: scale(.9);
+ transform: scale(.9)
+}
+
+.swagger-ui .checkbox input[type=checkbox]:checked+label>.item {
+ background: #e8e8e8 url("data:image/svg+xml;charset=utf-8,%3Csvg width='10' height='8' viewBox='3 7 10 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%2341474E' fill-rule='evenodd' d='M6.333 15L3 11.667l1.333-1.334 2 2L11.667 7 13 8.333z'/%3E%3C/svg%3E") 50% no-repeat
+}
+
+.swagger-ui .dialog-ux {
+ position: fixed;
+ z-index: 9999;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0
+}
+
+.swagger-ui .dialog-ux .backdrop-ux {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: rgba(0, 0, 0, .8)
+}
+
+.swagger-ui .dialog-ux .modal-ux {
+ position: absolute;
+ z-index: 9999;
+ top: 50%;
+ left: 50%;
+ width: 100%;
+ min-width: 300px;
+ max-width: 650px;
+ -webkit-transform: translate(-50%, -50%);
+ transform: translate(-50%, -50%);
+ border: 1px solid #ebebeb;
+ border-radius: 4px;
+ background: #fff;
+ box-shadow: none;
+}
+
+.swagger-ui .dialog-ux .modal-ux-content {
+ overflow-y: auto;
+ max-height: 540px;
+ padding: 20px
+}
+
+.swagger-ui .dialog-ux .modal-ux-content p {
+ font-size: 12px;
+ margin: 0 0 5px;
+ color: #41444e;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .dialog-ux .modal-ux-content h4 {
+ font-size: 18px;
+ font-weight: 600;
+ margin: 15px 0 0;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .dialog-ux .modal-ux-header {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ padding: 12px 0;
+ border-bottom: 1px solid #ebebeb;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.swagger-ui .dialog-ux .modal-ux-header .close-modal {
+ padding: 0 10px;
+ border: none;
+ background: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none
+}
+
+.swagger-ui .dialog-ux .modal-ux-header h3 {
+ font-size: 20px;
+ font-weight: 600;
+ margin: 0;
+ padding: 0 20px;
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .model {
+ font-size: 12px;
+ font-weight: 300;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151
+}
+
+.swagger-ui .model-toggle {
+ font-size: 10px;
+ position: relative;
+ top: 6px;
+ display: inline-block;
+ margin: auto .3em;
+ cursor: pointer;
+ -webkit-transition: -webkit-transform .15s ease-in;
+ transition: -webkit-transform .15s ease-in;
+ transition: transform .15s ease-in;
+ transition: transform .15s ease-in, -webkit-transform .15s ease-in;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+ -webkit-transform-origin: 50% 50%;
+ transform-origin: 50% 50%
+}
+
+.swagger-ui .model-toggle.collapsed {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg)
+}
+
+.swagger-ui .model-toggle:after {
+ display: block;
+ width: 20px;
+ height: 20px;
+ content: "";
+ background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z'/%3E%3C/svg%3E") 50% no-repeat;
+ background-size: 100%
+}
+
+.swagger-ui .model-jump-to-path {
+ position: relative;
+ cursor: pointer
+}
+
+.swagger-ui .model-jump-to-path .view-line-link {
+ position: absolute;
+ top: -.4em;
+ cursor: pointer
+}
+
+.swagger-ui .model-title {
+ position: relative
+}
+
+.swagger-ui .model-title:hover .model-hint {
+ visibility: visible
+}
+
+.swagger-ui .model-hint {
+ position: absolute;
+ top: -1.8em;
+ visibility: hidden;
+ padding: .1em .5em;
+ white-space: nowrap;
+ color: #ebebeb;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, .7)
+}
+
+.swagger-ui section.models {
+ margin: 30px 0;
+ border: 1px solid #EAEAEF;
+ border-radius: 4px
+}
+
+.swagger-ui section.models.is-open {
+ padding: 0 0 20px
+}
+
+.swagger-ui section.models.is-open h4 {
+ margin: 0 0 5px;
+ border-bottom: 1px solid #EAEAEF
+}
+
+.swagger-ui section.models.is-open h4 svg {
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg)
+}
+
+.swagger-ui section.models h4 {
+ font-size: 16px;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ margin: 0;
+ padding: 10px 20px 10px 10px;
+ cursor: pointer;
+ -webkit-transition: all .2s;
+ transition: all .2s;
+ font-family: Titillium Web, sans-serif;
+ color: #777;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.swagger-ui section.models h4 svg {
+ -webkit-transition: all .4s;
+ transition: all .4s
+}
+
+.swagger-ui section.models h4 span {
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1
+}
+
+.swagger-ui section.models h4:hover {
+ 57rg57a(60, 0, 0, .02)
+}
+
+.swagger-ui section.models h5 {
+ font-size: 16px;
+ margin: 0 0 10px;
+ font-family: Titillium Web, sans-serif;
+ color: #777
+}
+
+.swagger-ui section.models .model-jump-to-path {
+ position: relative;
+ top: 5px
+}
+
+.swagger-ui section.models .model-container {
+ margin: 0 20px 15px;
+ -webkit-transition: all .5s;
+ transition: all .5s;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, .05)
+}
+
+.swagger-ui section.models .model-container:hover {
+ background: rgba(0, 0, 0, .07)
+}
+
+.swagger-ui section.models .model-container:first-of-type {
+ margin: 20px
+}
+
+.swagger-ui section.models .model-container:last-of-type {
+ margin: 0 20px
+}
+
+.swagger-ui section.models .model-box {
+ background: none
+}
+
+.swagger-ui .model-box {
+ padding: 10px;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, .1)
+}
+
+.swagger-ui .model-box .model-jump-to-path {
+ position: relative;
+ top: 4px
+}
+
+.swagger-ui .model-title {
+ font-size: 16px;
+ font-family: Titillium Web, sans-serif;
+ color: #555
+}
+
+.swagger-ui span>span.model, .swagger-ui span>span.model .brace-close {
+ padding: 0 0 0 10px
+}
+
+.swagger-ui .prop-type {
+ color: #55a
+}
+
+.swagger-ui .prop-enum {
+ display: block
+}
+
+.swagger-ui .prop-format {
+ color: #999
+}
+
+.swagger-ui table {
+ width: 100%;
+ padding: 0 10px;
+ border-collapse: collapse
+}
+
+.swagger-ui table.model tbody tr td {
+ padding: 0;
+ vertical-align: top
+}
+
+.swagger-ui table.model tbody tr td:first-of-type {
+ width: 100px;
+ padding: 0
+}
+
+.swagger-ui table.headers td {
+ font-size: 12px;
+ font-weight: 300;
+ vertical-align: middle;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151
+}
+
+.swagger-ui table tbody tr td {
+ padding: 10px 0 0;
+ vertical-align: top
+}
+
+.swagger-ui table tbody tr td:first-of-type {
+ width: 20%;
+ padding: 10px 0
+}
+
+.swagger-ui table thead tr td, .swagger-ui table thead tr th {
+ font-size: 12px;
+ font-weight: 700;
+ padding: 12px 0;
+ text-align: left;
+ border-bottom: 1px solid rgba(59, 65, 81, .2);
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .parameters-col_description p {
+ font-size: 14px;
+ margin: 0;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .parameters-col_description input[type=text] {
+ width: 100%;
+ max-width: 340px
+}
+
+.swagger-ui .parameter__name {
+ font-size: 16px;
+ font-weight: 400;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .parameter__name.required {
+ font-weight: 700
+}
+
+.swagger-ui .parameter__name.required:after {
+ font-size: 10px;
+ position: relative;
+ top: -6px;
+ padding: 5px;
+ content: "required";
+ color: rgba(255, 0, 0, .6)
+}
+
+.swagger-ui .parameter__in {
+ font-size: 12px;
+ font-style: italic;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #888
+}
+
+.swagger-ui .table-container {
+ padding: 20px
+}
+
+.swagger-ui .topbar {
+ padding: 8px 30px;
+ background-color: #2430F0
+}
+
+.swagger-ui .topbar .topbar-wrapper {
+ -ms-flex-align: center
+}
+
+.swagger-ui .topbar .topbar-wrapper, .swagger-ui .topbar a {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ align-items: center
+}
+
+.swagger-ui .topbar a {
+ font-size: 1.5em;
+ font-weight: 700;
+ text-decoration: none;
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ -ms-flex-align: center;
+ font-family: Titillium Web, sans-serif;
+ color: #fff
+}
+
+.swagger-ui .topbar a span {
+ margin: 0;
+ padding: 0 10px
+}
+
+.swagger-ui .topbar .download-url-wrapper {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex
+}
+
+.swagger-ui .topbar .download-url-wrapper input[type=text] {
+ min-width: 350px;
+ margin: 0;
+ border: 2px solid #547f00;
+ border-radius: 4px 0 0 4px;
+ outline: none
+}
+
+.swagger-ui .topbar .download-url-wrapper .download-url-button {
+ font-size: 16px;
+ font-weight: 700;
+ padding: 4px 40px;
+ border: none;
+ border-radius: 0 4px 4px 0;
+ background: #547f00;
+ font-family: Titillium Web, sans-serif;
+ color: #fff
+}
+
+.swagger-ui .info {
+ margin: 50px 0
+}
+
+.swagger-ui .info hgroup.main {
+ margin: 0 0 20px
+}
+
+.swagger-ui .info hgroup.main a {
+ font-size: 12px
+}
+
+.swagger-ui .info p {
+ font-size: 14px;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .info code {
+ padding: 3px 5px;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, .05);
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #9012fe
+}
+
+.swagger-ui .info a {
+ font-size: 14px;
+ -webkit-transition: all .4s;
+ transition: all .4s;
+ font-family: Open Sans, sans-serif;
+ color: #2430F0
+}
+
+.swagger-ui .info a:hover {
+ color: #1f69c0
+}
+
+.swagger-ui .info>div {
+ margin: 0 0 5px
+}
+
+.swagger-ui .info .base-url {
+ font-size: 12px;
+ font-weight: 300!important;
+ margin: 0;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151
+}
+
+.swagger-ui .info .title {
+ font-size: 36px;
+ margin: 0;
+ font-family: Open Sans, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .info .title small {
+ font-size: 10px;
+ position: relative;
+ top: -5px;
+ display: inline-block;
+ margin: 0 0 0 5px;
+ padding: 2px 4px;
+ vertical-align: super;
+ border-radius: 57px;
+ background: #7d8492
+}
+
+.swagger-ui .info .title small pre {
+ margin: 0;
+ font-family: Titillium Web, sans-serif;
+ color: #fff
+}
+
+.swagger-ui .auth-btn-wrapper {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ padding: 10px 0;
+ -webkit-box-pack: center;
+ -ms-flex-pack: center;
+ justify-content: center
+}
+
+.swagger-ui .auth-wrapper {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ -webkit-box-pack: end;
+ -ms-flex-pack: end;
+ justify-content: flex-end
+}
+
+.swagger-ui .auth-wrapper .authorize {
+ padding-right: 20px
+}
+
+.swagger-ui .auth-container {
+ margin: 0 0 10px;
+ padding: 10px 20px;
+ border-bottom: 1px solid #ebebeb
+}
+
+.swagger-ui .auth-container:last-of-type {
+ margin: 0;
+ padding: 10px 20px;
+ border: 0
+}
+
+.swagger-ui .auth-container h4 {
+ margin: 5px 0 15px!important
+}
+
+.swagger-ui .auth-container .wrapper {
+ margin: 0;
+ padding: 0
+}
+
+.swagger-ui .auth-container input[type=password], .swagger-ui .auth-container input[type=text] {
+ min-width: 230px
+}
+
+.swagger-ui .auth-container .errors {
+ font-size: 12px;
+ padding: 10px;
+ border-radius: 4px;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151
+}
+
+.swagger-ui .scopes h2 {
+ font-size: 14px;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+.swagger-ui .scope-def {
+ padding: 0 0 20px
+}
+
+.swagger-ui .errors-wrapper {
+ margin: 20px;
+ padding: 10px 20px;
+ -webkit-animation: scaleUp .5s;
+ animation: scaleUp .5s;
+ border: 2px solid #f46470;
+ border-radius: 4px;
+ background: rgba(249, 62, 62, .1)
+}
+
+.swagger-ui .errors-wrapper .error-wrapper {
+ margin: 0 0 10px
+}
+
+.swagger-ui .errors-wrapper .errors h4 {
+ font-size: 14px;
+ margin: 0;
+ font-family: Source Code Pro, monospace;
+ font-weight: 600;
+ color: #3b4151
+}
+
+.swagger-ui .errors-wrapper hgroup {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.swagger-ui .errors-wrapper hgroup h4 {
+ font-size: 20px;
+ margin: 0;
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ font-family: Titillium Web, sans-serif;
+ color: #3b4151
+}
+
+@-webkit-keyframes scaleUp {
+ 0% {
+ -webkit-transform: scale(.8);
+ transform: scale(.8);
+ opacity: 0
+ }
+ to {
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ opacity: 1
+ }
+}
+
+@keyframes scaleUp {
+ 0% {
+ -webkit-transform: scale(.8);
+ transform: scale(.8);
+ opacity: 0
+ }
+ to {
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ opacity: 1
+ }
+}
+
+.swagger-ui .Resizer.vertical.disabled {
+ display: none
+}
+
+/*# sourceMappingURL=swagger-ui.css.map*/
+
+/**
+ * Swagger UI Theme Overrides
+ *
+ * Theme: Newspaper
+ * Author: Mark Ostrander
+ * Github: https://github.com/ostranme/swagger-ui-themes
+ */
+
+ .swagger-ui .opblock.opblock-post {
+ border-color: #F2F2F6;
+ background: #F5F7F9;
+ }
+
+ .swagger-ui .opblock.opblock-post .opblock-summary-method {
+ background: #535356;
+ }
+
+ .swagger-ui .opblock.opblock-post .opblock-summary {
+ border-color: #F2F2F6;
+ }
+
+ .swagger-ui .opblock.opblock-put {
+ border-color: #F2F2F6;
+ background: #F5F7F9;
+ }
+
+ .swagger-ui .opblock.opblock-put .opblock-summary-method {
+ background: #535356;
+ }
+
+ .swagger-ui .opblock.opblock-put .opblock-summary {
+ border-color: #F2F2F6;
+ }
+
+ .swagger-ui .opblock.opblock-delete {
+ border-color: #F2F2F6;
+ background: #F5F7F9;
+ }
+
+ .swagger-ui .opblock.opblock-delete .opblock-summary-method {
+ background: #535356;
+ }
+
+ .swagger-ui .opblock.opblock-delete .opblock-summary {
+ border-color: #F2F2F6;
+ }
+
+ .swagger-ui .opblock.opblock-get {
+ border-color: #F2F2F6;
+ background: #F5F7F9;
+ }
+
+ .swagger-ui .opblock.opblock-get .opblock-summary-method {
+ background: #535356;
+ }
+
+ .swagger-ui .opblock.opblock-get .opblock-summary {
+ border-color: #F2F2F6;
+ }
+
+ .swagger-ui .opblock.opblock-patch {
+ border-color: #F2F2F6;
+ background: #F5F7F9;
+ }
+
+ .swagger-ui .opblock.opblock-patch .opblock-summary-method {
+ background: #535356;
+ }
+
+ .swagger-ui .opblock.opblock-patch .opblock-summary {
+ border-color: #F2F2F6;
+ }
+
+ .swagger-ui .opblock.opblock-head {
+ border-color: #F2F2F6;
+ background: #F5F7F9;
+ }
+
+ .swagger-ui .opblock.opblock-head .opblock-summary-method {
+ background: #535356;
+ }
+
+ .swagger-ui .opblock.opblock-head .opblock-summary {
+ border-color: #F2F2F6;
+ }
+
+ .swagger-ui .opblock.opblock-options {
+ border-color: #F2F2F6;
+ background: #F5F7F9;
+ }
+
+ .swagger-ui .opblock.opblock-options .opblock-summary-method {
+ background: #535356;
+ }
+
+ .swagger-ui .opblock.opblock-options .opblock-summary {
+ border-color: #F2F2F6;
+ }
+
+ .swagger-ui .topbar {
+ padding: 8px 30px;
+ background-color: #535356;
+ }
+ .swagger-ui .topbar .download-url-wrapper input[type=text] {
+ min-width: 350px;
+ margin: 0;
+ border: 2px solid #F2F2F6;
+ border-radius: 4px 0 0 4px;
+ outline: none;
+ }
+
+ .swagger-ui .topbar .download-url-wrapper .download-url-button {
+ font-size: 16px;
+ font-weight: 700;
+ padding: 4px 40px;
+ border: none;
+ border-radius: 0 4px 4px 0;
+ background: #F2F2F6;
+ font-family: Titillium Web, sans-serif;
+ color: #535356;
+ }
+
+ .swagger-ui .info a {
+ font-size: 14px;
+ -webkit-transition: all .4s;
+ transition: all .4s;
+ font-family: Open Sans, sans-serif;
+ color: #535356;
+ }
+
+ .swagger-ui .info a:hover {
+ color: #535356;
+ }
+
+ .swagger-ui .btn.authorize {
+ line-height: 1;
+ display: inline;
+ color: #535356;
+ border-color: #535356;
+ }
+ .swagger-ui .btn.authorize svg {
+ fill: #535356;
+ }
+`
+ })
+ }
+
await app.listen(configService.get('PORT') || DEFAULT_PORT)
}
bootstrap()
diff --git a/packages/core/manifest/src/manifest/controllers/manifest.controller.ts b/packages/core/manifest/src/manifest/controllers/manifest.controller.ts
index 17154a90..7fc17262 100644
--- a/packages/core/manifest/src/manifest/controllers/manifest.controller.ts
+++ b/packages/core/manifest/src/manifest/controllers/manifest.controller.ts
@@ -3,7 +3,9 @@ import { Controller, Get, Param, Req } from '@nestjs/common'
import { Request } from 'express'
import { AuthService } from '../../auth/auth.service'
import { ManifestService } from '../services/manifest/manifest.service'
+import { ApiTags } from '@nestjs/swagger'
+@ApiTags('Manifest')
@Controller('manifest')
export class ManifestController {
constructor(
diff --git a/packages/core/manifest/src/manifest/json-schema/definitions/property-schema.json b/packages/core/manifest/src/manifest/json-schema/definitions/property-schema.json
index 15574c1b..468f793e 100644
--- a/packages/core/manifest/src/manifest/json-schema/definitions/property-schema.json
+++ b/packages/core/manifest/src/manifest/json-schema/definitions/property-schema.json
@@ -21,6 +21,7 @@
"link",
"money",
"date",
+ "timestamp",
"email",
"boolean",
"relation",
diff --git a/packages/core/manifest/src/manifest/json-schema/schema.json b/packages/core/manifest/src/manifest/json-schema/schema.json
index a023d36c..deb11686 100644
--- a/packages/core/manifest/src/manifest/json-schema/schema.json
+++ b/packages/core/manifest/src/manifest/json-schema/schema.json
@@ -1,14 +1,18 @@
{
- "$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schema.manifest.build/schema.json",
+ "$schema": "http://json-schema.org/draft-07/schema#",
"title": "App Manifest Schema",
- "description": "A complete backend in a single file.",
+ "description": "A complete backend in a single file",
"type": "object",
"properties": {
"name": {
"description": "The name of your app",
"type": "string"
},
+ "version": {
+ "description": "The version of your app",
+ "type": "string"
+ },
"entities": {
"description": "The entities in your app. Doc: https://manifest.build/docs/entities",
"type": "object",
diff --git a/packages/core/manifest/src/manifest/services/manifest/manifest.service.ts b/packages/core/manifest/src/manifest/services/manifest/manifest.service.ts
index 5db58170..d6db0406 100644
--- a/packages/core/manifest/src/manifest/services/manifest/manifest.service.ts
+++ b/packages/core/manifest/src/manifest/services/manifest/manifest.service.ts
@@ -126,12 +126,16 @@ export class ManifestService {
}
/**
- * Transform an AppManifestSchema into an AppManifest.
+ * Transform an AppManifestSchema into an AppManifest ensuring that undefined properties are filled in with defaults.
*
* @param manifestSchema the manifest schema to transform.
* @returns the manifest with defaults filled in and short form properties transformed into long form.
*/
transformAppManifest(manifestSchema: AppManifestSchema): AppManifest {
+ if (!manifestSchema.version) {
+ manifestSchema.version = '0.0.1'
+ }
+
if (!manifestSchema.entities) {
manifestSchema.entities = {}
}
diff --git a/packages/core/manifest/src/manifest/services/yaml/yaml.service.spec.ts b/packages/core/manifest/src/manifest/services/yaml/yaml.service.spec.ts
index 2756b1d1..81411813 100644
--- a/packages/core/manifest/src/manifest/services/yaml/yaml.service.spec.ts
+++ b/packages/core/manifest/src/manifest/services/yaml/yaml.service.spec.ts
@@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'
import { YamlService } from './yaml.service'
import * as fs from 'fs'
+import { ConfigService } from '@nestjs/config'
jest.mock('fs')
@@ -15,7 +16,17 @@ describe('YamlService', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
- providers: [YamlService]
+ providers: [
+ YamlService,
+ {
+ provide: ConfigService,
+ useValue: {
+ get: jest.fn(() => ({
+ database: 'mocked database path'
+ }))
+ }
+ }
+ ]
}).compile()
service = module.get(YamlService)
diff --git a/packages/core/manifest/src/manifest/services/yaml/yaml.service.ts b/packages/core/manifest/src/manifest/services/yaml/yaml.service.ts
index 97cce022..a72f25ae 100644
--- a/packages/core/manifest/src/manifest/services/yaml/yaml.service.ts
+++ b/packages/core/manifest/src/manifest/services/yaml/yaml.service.ts
@@ -4,10 +4,12 @@ import * as fs from 'fs'
import * as yaml from 'js-yaml'
import { AppManifestSchema } from '@mnfst/types'
-import { MANIFEST_FILE_NAME, MANIFEST_FOLDER_NAME } from '../../../constants'
+import { ConfigService } from '@nestjs/config'
@Injectable()
export class YamlService {
+ constructor(private readonly configService: ConfigService) {}
+
/**
*
* Load the manifest from the YML file and transform it into a AppManifest object.
@@ -17,7 +19,7 @@ export class YamlService {
**/
load(): AppManifestSchema {
let fileContent: string = fs.readFileSync(
- `${process.cwd()}/${MANIFEST_FOLDER_NAME}/${MANIFEST_FILE_NAME}`,
+ this.configService.get('paths').database,
'utf8'
)
diff --git a/packages/core/manifest/src/open-api/open-api.module.ts b/packages/core/manifest/src/open-api/open-api.module.ts
new file mode 100644
index 00000000..722cdf67
--- /dev/null
+++ b/packages/core/manifest/src/open-api/open-api.module.ts
@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common'
+import { OpenApiService } from './services/open-api.service'
+import { OpenApiCrudService } from './services/open-api-crud.service'
+import { ManifestModule } from '../manifest/manifest.module'
+import { OpenApiManifestService } from './services/open-api-manifest.service'
+
+@Module({
+ imports: [ManifestModule],
+ providers: [OpenApiService, OpenApiCrudService, OpenApiManifestService]
+})
+export class OpenApiModule {}
diff --git a/packages/core/manifest/src/open-api/services/open-api-crud.service.ts b/packages/core/manifest/src/open-api/services/open-api-crud.service.ts
new file mode 100644
index 00000000..558386da
--- /dev/null
+++ b/packages/core/manifest/src/open-api/services/open-api-crud.service.ts
@@ -0,0 +1,321 @@
+import { EntityManifest } from '@mnfst/types'
+import { Injectable } from '@nestjs/common'
+import { PathItemObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'
+
+@Injectable()
+export class OpenApiCrudService {
+ /**
+ * Generates the paths for the entities. For each entity, it generates the paths for listing, creating, updating and deleting.
+ *
+ * @param entityManifests The entity manifests.
+ * @returns The paths object.
+ *
+ */
+ generateEntityPaths(
+ entityManifests: EntityManifest[]
+ ): Record {
+ const paths: Record = {}
+
+ entityManifests.forEach((entityManifest: EntityManifest) => {
+ paths[`/api/dynamic/${entityManifest.slug}`] = {
+ ...this.generateListPath(entityManifest),
+ ...this.generateCreatePath(entityManifest)
+ }
+ paths[`/api/dynamic/${entityManifest.slug}/select-options`] =
+ this.generateListSelectOptionsPath(entityManifest)
+ paths[`/api/dynamic/${entityManifest.slug}/{id}`] = {
+ ...this.generateDetailPath(entityManifest),
+ ...this.generateUpdatePath(entityManifest),
+ ...this.generateDeletePath(entityManifest)
+ }
+ })
+
+ return paths
+ }
+
+ /**
+ * Generates the path for listing entities.
+ *
+ * @param entityManifest The entity manifest.
+ * @returns The path item object.
+ *
+ */
+ generateListPath(entityManifest: EntityManifest): PathItemObject {
+ return {
+ get: {
+ summary: `List ${entityManifest.namePlural}`,
+ description: `Retrieves a paginated list of ${entityManifest.namePlural}. In addition to the general parameters below, each property of the ${entityManifest.nameSingular} can be used as a filter: https://manifest.build/docs/rest-api#filters`,
+ tags: [this.capitalizeFirstLetter(entityManifest.namePlural)],
+ parameters: [
+ {
+ name: 'page',
+ in: 'query',
+ description: 'The page number',
+ required: false,
+ schema: {
+ type: 'integer',
+ default: 1
+ }
+ },
+ {
+ name: 'perPage',
+ in: 'query',
+ description: 'The number of items per page',
+ required: false,
+ schema: {
+ type: 'integer',
+ default: 10
+ }
+ },
+ {
+ name: 'orderBy',
+ in: 'query',
+ description: 'The field to order by',
+ required: false,
+ schema: {
+ type: 'string'
+ }
+ },
+ {
+ name: 'order',
+ in: 'query',
+ description: 'The order direction',
+ required: false,
+ schema: {
+ type: 'string',
+ enum: ['ASC', 'DESC']
+ }
+ },
+ {
+ name: 'relations',
+ in: 'query',
+ description:
+ 'The relations to include. For several relations, use a comma-separated list',
+ required: false,
+ schema: {
+ type: 'string'
+ }
+ }
+ ],
+ responses: {
+ '200': {
+ description: 'OK',
+ content: {
+ 'application/json': {
+ schema: {
+ $ref: '#/components/schemas/Paginator'
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates the path for listing entities for select options.
+ * This is used to fill select dropdown options.
+ *
+ * @param entityManifest The entity manifest.
+ * @returns The path item object.
+ *
+ */
+ generateListSelectOptionsPath(
+ entityManifest: EntityManifest
+ ): PathItemObject {
+ return {
+ get: {
+ summary: `List ${entityManifest.namePlural} for select options`,
+ description: `Retrieves a list of ${entityManifest.namePlural} for select options. The response is an array of objects with the properties 'id' and 'label'.`,
+ tags: [this.capitalizeFirstLetter(entityManifest.namePlural)],
+ responses: {
+ '200': {
+ description: `List of ${entityManifest.namePlural} for select options`,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: {
+ $ref: `#/components/schemas/SelectOption`
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates the path for creating an entity.
+ * This is used to create a new entity.
+ *
+ * @param entityManifest The entity manifest.
+ * @returns The path item object.
+ *
+ */
+ generateCreatePath(entityManifest: EntityManifest): PathItemObject {
+ return {
+ post: {
+ summary: `Create a new ${entityManifest.nameSingular}`,
+ description: `Creates a new ${entityManifest.nameSingular} passing the properties in the request body as JSON.`,
+ tags: [this.capitalizeFirstLetter(entityManifest.namePlural)],
+ requestBody: {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object'
+ }
+ }
+ }
+ },
+ responses: {
+ '201': {
+ description: `OK`
+ },
+ '400': {
+ description: `Bad request`
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates the path for retrieving the details of an entity.
+ *
+ * @param entityManifest The entity manifest.
+ * @returns The path item object.
+ *
+ */
+ generateDetailPath(entityManifest: EntityManifest): PathItemObject {
+ return {
+ get: {
+ summary: `Get a single ${entityManifest.nameSingular}`,
+ description: `Retrieves the details of a single ${entityManifest.nameSingular} by its ID.`,
+ tags: [this.capitalizeFirstLetter(entityManifest.namePlural)],
+ parameters: [
+ {
+ name: 'id',
+ in: 'path',
+ description: `The ID of the ${entityManifest.nameSingular}`,
+ required: true,
+ schema: {
+ type: 'integer'
+ }
+ }
+ ],
+ responses: {
+ '200': {
+ description: `OK`,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object'
+ }
+ }
+ }
+ },
+ '404': {
+ description: `The ${entityManifest.nameSingular} was not found`
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates the path for updating an entity.
+ *
+ * @param entityManifest The entity manifest.
+ * @returns The path item object.
+ *
+ */
+ generateUpdatePath(entityManifest: EntityManifest): PathItemObject {
+ return {
+ put: {
+ summary: `Update an existing ${entityManifest.nameSingular}`,
+ description: `Updates a single ${entityManifest.nameSingular} by its ID. The properties to update are passed in the request body as JSON.`,
+ tags: [this.capitalizeFirstLetter(entityManifest.namePlural)],
+ requestBody: {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object'
+ }
+ }
+ }
+ },
+ parameters: [
+ {
+ name: 'id',
+ in: 'path',
+ description: `The ID of the ${entityManifest.nameSingular}`,
+ required: true,
+ schema: {
+ type: 'integer'
+ }
+ }
+ ],
+ responses: {
+ '200': {
+ description: `OK`,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object'
+ }
+ }
+ }
+ },
+ '404': {
+ description: `Not found`
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates the path for deleting an entity.
+ *
+ * @param entityManifest The entity manifest.
+ * @returns The path item object.
+ *
+ */
+ generateDeletePath(entityManifest: EntityManifest): PathItemObject {
+ return {
+ delete: {
+ summary: `Delete an existing ${entityManifest.nameSingular}`,
+ description: `Deletes a single ${entityManifest.nameSingular} by its ID.`,
+ tags: [this.capitalizeFirstLetter(entityManifest.namePlural)],
+ parameters: [
+ {
+ name: 'id',
+ in: 'path',
+ description: `The ID of the ${entityManifest.nameSingular}`,
+ required: true,
+ schema: {
+ type: 'integer'
+ }
+ }
+ ],
+ responses: {
+ '200': {
+ description: `OK`
+ },
+ '404': {
+ description: `The ${entityManifest.nameSingular} was not found`
+ }
+ }
+ }
+ }
+ }
+
+ private capitalizeFirstLetter(str: string) {
+ if (str.length === 0) return str // Handle empty string case
+ return str.charAt(0).toUpperCase() + str.slice(1)
+ }
+}
diff --git a/packages/core/manifest/src/open-api/services/open-api-manifest.service.ts b/packages/core/manifest/src/open-api/services/open-api-manifest.service.ts
new file mode 100644
index 00000000..0c201271
--- /dev/null
+++ b/packages/core/manifest/src/open-api/services/open-api-manifest.service.ts
@@ -0,0 +1,77 @@
+import { AppManifest, EntityManifest } from '@mnfst/types'
+import { Injectable } from '@nestjs/common'
+import { PathItemObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'
+
+@Injectable()
+export class OpenApiManifestService {
+ /**
+ * Generates the paths for the manifest endpoints.
+ *
+ * @param appManifest The manifest of the application.
+ * @returns The paths for the manifest endpoints.
+ *
+ */
+ generateManifestPaths(
+ appManifest: AppManifest
+ ): Record {
+ const paths: Record = {
+ '/api/manifest': {
+ get: {
+ summary: 'Get the manifest',
+ description: 'Retrieves the manifest of the application.',
+ tags: ['Manifest'],
+ responses: {
+ '200': {
+ description: 'The manifest of the application.',
+ content: {
+ 'application/json': {
+ schema: {
+ $ref: '#/components/schemas/AppManifest'
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Object.values(appManifest.entities).forEach(
+ (entityManifest: EntityManifest) => {
+ paths[`/api/manifest/entities/${entityManifest.slug}`] =
+ this.generateEntityManifestPath(entityManifest)
+ }
+ )
+
+ return paths
+ }
+
+ /**
+ * Generates the path for the entity manifest endpoint.
+ *
+ * @param entityManifest The manifest of the entity.
+ * @returns The path for the entity manifest endpoint.
+ *
+ */
+ generateEntityManifestPath(entityManifest: EntityManifest): PathItemObject {
+ return {
+ get: {
+ summary: `Get the ${entityManifest.nameSingular} manifest`,
+ description: `Retrieves the manifest of the ${entityManifest.nameSingular} entity with all its properties.`,
+ tags: ['Manifest'],
+ responses: {
+ '200': {
+ description: `The manifest of the ${entityManifest.nameSingular} entity.`,
+ content: {
+ 'application/json': {
+ schema: {
+ $ref: '#/components/schemas/EntityManifest'
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/core/manifest/src/open-api/services/open-api.service.ts b/packages/core/manifest/src/open-api/services/open-api.service.ts
new file mode 100644
index 00000000..5903fc97
--- /dev/null
+++ b/packages/core/manifest/src/open-api/services/open-api.service.ts
@@ -0,0 +1,157 @@
+import { Injectable } from '@nestjs/common'
+import { OpenApiCrudService } from './open-api-crud.service'
+import { OpenAPIObject } from '@nestjs/swagger'
+import { ManifestService } from '../../manifest/services/manifest/manifest.service'
+import { AppManifest } from '@mnfst/types'
+import { OpenApiManifestService } from './open-api-manifest.service'
+
+@Injectable()
+export class OpenApiService {
+ constructor(
+ private readonly manifestService: ManifestService,
+ private readonly openApiCrudService: OpenApiCrudService,
+ private readonly openApiManifestService: OpenApiManifestService
+ ) {}
+
+ /**
+ * Generates the OpenAPI object for the application.
+ *
+ * @returns The OpenAPI object.
+ *
+ */
+ generateOpenApiObject(): OpenAPIObject {
+ const appManifest: AppManifest = this.manifestService.getAppManifest()
+
+ return {
+ openapi: '3.1.0',
+ info: {
+ title: appManifest.name,
+ version: '' // Version is not supported yet.
+ },
+ paths: {
+ ...this.openApiCrudService.generateEntityPaths(
+ Object.values(appManifest.entities)
+ ),
+ ...this.openApiManifestService.generateManifestPaths(appManifest)
+ },
+ components: {
+ schemas: {
+ Paginator: {
+ type: 'object',
+ properties: {
+ data: {
+ type: 'array',
+ items: {
+ type: 'object'
+ }
+ },
+ currentPage: {
+ type: 'integer'
+ },
+ lastPage: {
+ type: 'integer'
+ },
+ from: {
+ type: 'integer'
+ },
+ to: {
+ type: 'integer'
+ },
+ total: {
+ type: 'integer'
+ },
+ perPage: {
+ type: 'integer'
+ }
+ }
+ },
+ SelectOption: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'number'
+ },
+ label: {
+ type: 'string'
+ }
+ }
+ },
+ AppManifest: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string'
+ },
+ entities: {
+ type: 'object',
+ additionalProperties: {
+ $ref: '#/components/schemas/EntityManifest'
+ }
+ }
+ }
+ },
+ EntityManifest: {
+ type: 'object',
+ properties: {
+ className: {
+ type: 'string'
+ },
+ nameSingular: {
+ type: 'string'
+ },
+ namePlural: {
+ type: 'string'
+ },
+ slug: {
+ type: 'string'
+ },
+ mainProp: {
+ type: 'string'
+ },
+ seedCount: {
+ type: 'number'
+ },
+ belongsTo: {
+ type: 'array',
+ items: {
+ $ref: '#/components/schemas/RelationshipManifest'
+ }
+ },
+ properties: {
+ type: 'array',
+ items: {
+ $ref: '#/components/schemas/PropertyManifest'
+ }
+ }
+ }
+ },
+ RelationshipManifest: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string'
+ },
+ entity: {
+ type: 'string'
+ },
+ eager: {
+ type: 'boolean'
+ }
+ }
+ },
+ PropertyManifest: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string'
+ },
+ type: {
+ type: 'string'
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/core/manifest/src/open-api/tests/open-api-crud.service.spec.ts b/packages/core/manifest/src/open-api/tests/open-api-crud.service.spec.ts
new file mode 100644
index 00000000..0d2e6714
--- /dev/null
+++ b/packages/core/manifest/src/open-api/tests/open-api-crud.service.spec.ts
@@ -0,0 +1,56 @@
+import { Test, TestingModule } from '@nestjs/testing'
+import { OpenApiCrudService } from '../services/open-api-crud.service'
+import { EntityManifest, PropType } from '@mnfst/types'
+
+describe('OpenApiCrudService', () => {
+ let service: OpenApiCrudService
+
+ const dummyEntityManifest: EntityManifest = {
+ className: 'Cat',
+ nameSingular: 'cat',
+ namePlural: 'cats',
+ slug: 'cats',
+ mainProp: 'name',
+ seedCount: 50,
+ belongsTo: [],
+ properties: [
+ {
+ name: 'name',
+ type: PropType.String
+ }
+ ]
+ }
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [OpenApiCrudService]
+ }).compile()
+
+ service = module.get(OpenApiCrudService)
+ })
+
+ it('should be defined', () => {
+ expect(service).toBeDefined()
+ })
+
+ it('should generate all 6 entity paths', () => {
+ jest.spyOn(service, 'generateListPath').mockReturnValue({})
+ jest.spyOn(service, 'generateListSelectOptionsPath').mockReturnValue({})
+ jest.spyOn(service, 'generateCreatePath').mockReturnValue({})
+ jest.spyOn(service, 'generateDetailPath').mockReturnValue({})
+ jest.spyOn(service, 'generateUpdatePath').mockReturnValue({})
+ jest.spyOn(service, 'generateDeletePath').mockReturnValue({})
+
+ const paths = service.generateEntityPaths([dummyEntityManifest])
+
+ expect(paths).toBeDefined()
+ expect(Object.keys(paths).length).toBe(3)
+
+ expect(service.generateListPath).toHaveBeenCalled()
+ expect(service.generateListSelectOptionsPath).toHaveBeenCalled()
+ expect(service.generateCreatePath).toHaveBeenCalled()
+ expect(service.generateDetailPath).toHaveBeenCalled()
+ expect(service.generateUpdatePath).toHaveBeenCalled()
+ expect(service.generateDeletePath).toHaveBeenCalled()
+ })
+})
diff --git a/packages/core/manifest/src/open-api/tests/open-api-manifest.service.spec.ts b/packages/core/manifest/src/open-api/tests/open-api-manifest.service.spec.ts
new file mode 100644
index 00000000..87f36a31
--- /dev/null
+++ b/packages/core/manifest/src/open-api/tests/open-api-manifest.service.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing'
+import { OpenApiManifestService } from '../services/open-api-manifest.service'
+
+describe('OpenApiManifestService', () => {
+ let service: OpenApiManifestService
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [OpenApiManifestService]
+ }).compile()
+
+ service = module.get(OpenApiManifestService)
+ })
+
+ it('should be defined', () => {
+ expect(service).toBeDefined()
+ })
+})
diff --git a/packages/core/manifest/src/open-api/tests/open-api.service.spec.ts b/packages/core/manifest/src/open-api/tests/open-api.service.spec.ts
new file mode 100644
index 00000000..f2a4417d
--- /dev/null
+++ b/packages/core/manifest/src/open-api/tests/open-api.service.spec.ts
@@ -0,0 +1,93 @@
+import { Test, TestingModule } from '@nestjs/testing'
+import { OpenApiService } from '../services/open-api.service'
+import { AppManifest, EntityManifest, PropType } from '@mnfst/types'
+import { OpenAPIObject } from '@nestjs/swagger'
+import { OpenApiCrudService } from '../services/open-api-crud.service'
+import { ManifestService } from '../../manifest/services/manifest/manifest.service'
+import { OpenApiManifestService } from '../services/open-api-manifest.service'
+
+describe('OpenApiService', () => {
+ let service: OpenApiService
+ let openApiCrudService: OpenApiCrudService
+ let openApiManifestService: OpenApiManifestService
+
+ const dummyAppManifest: AppManifest = {
+ name: 'Test App',
+ entities: {
+ Invoice: {
+ className: 'Invoice',
+ properties: [
+ {
+ name: 'name',
+ type: PropType.String
+ }
+ ],
+ belongsTo: []
+ }
+ }
+ }
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ OpenApiService,
+ {
+ provide: OpenApiCrudService,
+ useValue: {
+ generateEntityPaths: jest.fn((entityManifest: EntityManifest) => {})
+ }
+ },
+ {
+ provide: OpenApiManifestService,
+ useValue: {
+ generateManifestPaths: jest.fn((appManifest: AppManifest) => {})
+ }
+ },
+ {
+ provide: ManifestService,
+ useValue: {
+ getAppManifest: jest.fn(() => dummyAppManifest)
+ }
+ }
+ ]
+ }).compile()
+
+ service = module.get(OpenApiService)
+ openApiCrudService = module.get(OpenApiCrudService)
+ openApiManifestService = module.get(
+ OpenApiManifestService
+ )
+ })
+
+ it('should be defined', () => {
+ expect(service).toBeDefined()
+ })
+
+ it('should return an OpenAPIObject', () => {
+ const openApiObject: OpenAPIObject = service.generateOpenApiObject()
+
+ expect(openApiObject.openapi).toBe('3.1.0')
+ expect(openApiObject.info.title).toBe('Test App')
+ expect(openApiObject.info.version).toBe('')
+ })
+
+ it('should generate paths for each entity', () => {
+ jest.spyOn(openApiCrudService, 'generateEntityPaths')
+
+ service.generateOpenApiObject()
+
+ expect(openApiCrudService.generateEntityPaths).toHaveBeenCalledTimes(
+ Object.keys(dummyAppManifest.entities).length
+ )
+ })
+
+ it('should generate the manifest paths', () => {
+ jest.spyOn(openApiManifestService, 'generateManifestPaths')
+
+ service.generateOpenApiObject()
+
+ expect(openApiManifestService.generateManifestPaths).toHaveBeenCalledWith(
+ dummyAppManifest
+ )
+ })
+})
diff --git a/packages/core/types/README.md b/packages/core/types/README.md
index 5613a4a3..100a0156 100644
--- a/packages/core/types/README.md
+++ b/packages/core/types/README.md
@@ -2,6 +2,16 @@
Utility types used by Manifest.
+## Development
+
+When updating this library, you may need to fetch your local copy of it instead of the published packages from other projects.
+
+The `packages/core/admin` and `packages/core/manifest` projects in this repository have a command to do that it their package.json file. Simply run:
+
+```bash
+npm run link-local-types
+```
+
## Build
```
diff --git a/packages/core/types/package.json b/packages/core/types/package.json
index 2fe821cc..0a7dda14 100644
--- a/packages/core/types/package.json
+++ b/packages/core/types/package.json
@@ -1,6 +1,6 @@
{
"name": "@mnfst/types",
- "version": "0.1.0-alpha.3",
+ "version": "0.1.0-alpha.4",
"description": "Utility types used by Manifest",
"author": "Manifest",
"homepage": "https://manifest.build",
diff --git a/packages/core/types/src/crud/PropType.ts b/packages/core/types/src/crud/PropType.ts
index 5fccdbae..241cc506 100644
--- a/packages/core/types/src/crud/PropType.ts
+++ b/packages/core/types/src/crud/PropType.ts
@@ -8,6 +8,7 @@ export enum PropType {
Link = 'link',
Money = 'money',
Date = 'date',
+ Timestamp = 'timestamp',
Email = 'email',
Boolean = 'boolean',
Password = 'password',
diff --git a/packages/core/types/src/manifests/AppManifest.ts b/packages/core/types/src/manifests/AppManifest.ts
index 24195227..dbe270d8 100644
--- a/packages/core/types/src/manifests/AppManifest.ts
+++ b/packages/core/types/src/manifests/AppManifest.ts
@@ -7,6 +7,11 @@ export interface AppManifest extends AppManifestSchema {
*/
name: string
+ /**
+ * The version of the app.
+ */
+ version?: string
+
/**
* The entities of the app.
*/
diff --git a/packages/core/types/src/manifests/ManifestSchema.ts b/packages/core/types/src/manifests/ManifestSchema.ts
index f35536a1..93f240b6 100644
--- a/packages/core/types/src/manifests/ManifestSchema.ts
+++ b/packages/core/types/src/manifests/ManifestSchema.ts
@@ -24,6 +24,7 @@ export type PropertyManifestSchema =
| 'link'
| 'money'
| 'date'
+ | 'timestamp'
| 'email'
| 'boolean'
| 'relation'
@@ -67,13 +68,17 @@ export type RelationshipManifestSchema =
| string
/**
- * A complete backend in a single file.
+ * A complete backend in a single file
*/
export interface AppManifestSchema {
/**
* The name of your app
*/
name: string
+ /**
+ * The version of your app
+ */
+ version?: string
/**
* The entities in your app. Doc: https://manifest.build/docs/entities
*/