Merge branch 'main' into test-extensibility

This commit is contained in:
Vitaly Kozyura
2021-11-03 13:32:34 +01:00
committed by GitHub
22 changed files with 427 additions and 5761 deletions

View File

@@ -5,9 +5,9 @@ name: CI
on: on:
push: push:
branches: [ master ] branches: [ main ]
pull_request: pull_request:
branches: [ master ] branches: [ main ]
jobs: jobs:
build: build:

View File

@@ -1,17 +1,22 @@
const { exec } = require ('child_process') const { exec } = require ('child_process')
const isWin = process.platform === 'win32'
const express = require ('express') const express = require ('express')
const fs = require ('fs') const fs = require ('fs')
const app = express() const app = express()
const { PORT=4444 } = process.env const { PORT=4444 } = process.env
const [,,port=PORT] = process.argv const [,,port=PORT,scope='@capire'] = process.argv
const cwd = __dirname const cwd = __dirname
// clean up on start (exit handler might not complete on Windows)
exec(isWin ? 'del *.tgz' : 'rm *.tgz', {cwd})
app.use('/-/:tarball', (req,res,next) => { app.use('/-/:tarball', (req,res,next) => {
console.debug ('GET', req.params) console.debug ('GET', req.params)
try { try {
const { tarball } = req.params const { tarball } = req.params
const [, pkg ] = /^capire-(\w+)/.exec(tarball) const [, pkg ] = /^\w+-(\w+)/.exec(tarball)
fs.lstat(tarball,(err => { fs.lstat(tarball,(err => {
if (err) exec(`npm pack ../${pkg}`,{cwd},next) if (err) exec(`npm pack ../${pkg}`,{cwd},next)
else next() else next()
@@ -25,12 +30,14 @@ app.use('/-/:tarball', (req,res,next) => {
app.use('/-', express.static(__dirname)) app.use('/-', express.static(__dirname))
app.get('/*', (req,res)=>{ app.get('/*', (req,res)=>{
const urlRegex = /^\/(@\w+)\/(\w+)/
const url = decodeURIComponent(req.url) const url = decodeURIComponent(req.url)
console.debug ('GET',url) console.debug ('GET',url)
try { try {
const [, capire, pkg ] = /^\/(@capire)\/(\w+)/.exec(url) if (!urlRegex.test(url)) return res.sendStatus(404)
const package = require (`${capire}/${pkg}/package.json`) const [, scpe, pkg ] = urlRegex.exec(url)
const tarball = `capire-${pkg}-${package.version}.tgz` const package = require (`${scpe}/${pkg}/package.json`)
const tarball = `${scpe.slice(1)}-${pkg}-${package.version}.tgz`
// https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md // https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md
res.json({ res.json({
"name": package.name, "name": package.name,
@@ -42,29 +49,30 @@ app.get('/*', (req,res)=>{
"name": package.name, "name": package.name,
"version": package.version, "version": package.version,
"dist": { "dist": {
"tarball": `http://localhost:${port}/-/${tarball}` "tarball": `/-/${tarball}`
}, },
} }
}, },
}) })
} catch (e) { } catch (e) {
console.error(e) if (e.code === 'MODULE_NOT_FOUND') return res.sendStatus(404)
res.sendStatus(404) console.error(e); throw e
} }
}) })
app.listen(port, ()=>{ const server = app.listen(port, ()=>{
console.log (`npm set @capire:registry=http://localhost:${port}`) const url = `http://localhost:${server.address().port}`
console.log (`@capire registry listening on http://localhost:${port}`) console.log (`npm set ${scope}:registry=${url}`)
exec(`npm set @capire:registry=http://localhost:${port}`) exec(`npm set ${scope}:registry=${url}`)
console.log (`${scope} registry listening on ${url}`)
}) })
const _exit = ()=>{ const _exit = ()=>{
console.log ('\nnpm conf rm @capire:registry') server.close()
exec('npm conf rm @capire:registry') exec(`npm conf rm "${scope}:registry"`, ()=> { process.exit() })
exec('rm *.tgz')
process.exit()
} }
process.on ('SIGTERM',_exit) process.on ('SIGTERM',_exit)
process.on ('SIGHUP',_exit) process.on ('SIGHUP',_exit)
process.on ('SIGINT',_exit) process.on ('SIGINT',_exit)

View File

@@ -105,5 +105,5 @@
"description": "### Summary\n\nThat's it! You have seen: \n- How to integrate database-specific functions in a CDS model.\n- How to switch between the two implementations for SQLite and SAP HANA." "description": "### Summary\n\nThat's it! You have seen: \n- How to integrate database-specific functions in a CDS model.\n- How to switch between the two implementations for SQLite and SAP HANA."
} }
], ],
"ref": "master" "ref": "main"
} }

View File

@@ -17,7 +17,7 @@ Find here a collection of samples for the [SAP Cloud Application Programming Mod
### Download ### Download
If you've [Git](https://git-scm.com/downloads) installed, clone this repo as shown below, otherwise [download as ZIP file](archive/master.zip). If you've [Git](https://git-scm.com/downloads) installed, clone this repo as shown below, otherwise [download as ZIP file](archive/main.zip).
```sh ```sh
git clone https://github.com/sap-samples/cloud-cap-samples samples git clone https://github.com/sap-samples/cloud-cap-samples samples

View File

@@ -34,7 +34,7 @@
<td>{{ book.author }}</td> <td>{{ book.author }}</td>
<td>{{ book.genre.name }}</td> <td>{{ book.genre.name }}</td>
<td class="rating-stars"> <td class="rating-stars">
{{ ('★'.repeat(Math.round(book.rating))+'☆☆☆☆☆').slice(0,5) }} {{ ('★'.repeat(Math.round(book.rating))+'☆☆☆☆☆').slice(0,5) }} ({{ book.numberOfReviews }})
</td> </td>
<td>{{ book.currency && book.currency.symbol }} {{ book.price }}</td> <td>{{ book.currency && book.currency.symbol }} {{ book.price }}</td>
</tr> </tr>

View File

@@ -1 +1,2 @@
exports.CatalogService = require('./srv/cat-service') const { CatalogService } = require('./srv/cat-service')
module.exports = { CatalogService }

View File

@@ -38,6 +38,11 @@
"credentials": { "credentials": {
"database": "sqlite.db" "database": "sqlite.db"
} }
},
"messaging": {
"[development]": { "kind": "file-based-messaging" },
"[hybrid]": { "kind": "enterprise-messaging-shared" },
"kind": "enterprise-messaging"
} }
} }
} }

View File

@@ -10,9 +10,11 @@ cds.once('bootstrap',(app)=>{
cds.once('served', require('./srv/mashup')) cds.once('served', require('./srv/mashup'))
// Swagger UI - see https://cap.cloud.sap/docs/advanced/openapi // Swagger UI - see https://cap.cloud.sap/docs/advanced/openapi
if (process.env.NODE_ENV !== 'production') { try {
const cds_swagger = require ('cds-swagger-ui-express') const cds_swagger = require ('cds-swagger-ui-express')
cds.once ('bootstrap', app => app.use (cds_swagger()) ) cds.once ('bootstrap', app => app.use (cds_swagger()) )
} catch (err) {
if (err.code !== 'MODULE_NOT_FOUND') throw err
} }

View File

@@ -12,6 +12,7 @@ using { sap.capire.bookshop.Books } from '@capire/bookshop';
using { ReviewsService.Reviews } from '@capire/reviews'; using { ReviewsService.Reviews } from '@capire/reviews';
extend Books with { extend Books with {
reviews : Composition of many Reviews on reviews.subject = $self.ID; reviews : Composition of many Reviews on reviews.subject = $self.ID;
numberOfReviews : Integer;
rating : Decimal; rating : Decimal;
} }

View File

@@ -41,8 +41,8 @@ module.exports = async()=>{ // called by server.js
// //
ReviewsService.on ('reviewed', (msg) => { ReviewsService.on ('reviewed', (msg) => {
console.debug ('> received:', msg.event, msg.data) console.debug ('> received:', msg.event, msg.data)
const { subject, rating } = msg.data const { subject, count, rating } = msg.data
return UPDATE(Books,subject).with({rating}) return UPDATE(Books,subject).with({ numberOfReviews:count, rating })
// ^ Note: the framework will execute this and take care for db.tx // ^ Note: the framework will execute this and take care for db.tx
}) })

5859
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,10 @@
"@sap/cds-dk": "*" "@sap/cds-dk": "*"
}, },
"devDependencies": { "devDependencies": {
"cds-swagger-ui-express": "^0.2.0", "chai": "^4.3.4",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1", "chai-as-promised": "^7.1.1",
"chai-subset": "^1.6.0", "chai-subset": "^1.6.0",
"sqlite3": "^5.0.0" "sqlite3": "^5"
}, },
"scripts": { "scripts": {
"cleanup": "rm -rf node_modules && rm -rf */node_modules && rm -rf */*/node_modules", "cleanup": "rm -rf node_modules && rm -rf */node_modules && rm -rf */*/node_modules",

View File

@@ -10,7 +10,8 @@ service ReviewsService {
// Async API // Async API
event reviewed : { event reviewed : {
subject : type of Reviews:subject; subject : type of Reviews:subject;
rating: Decimal(2,1) count : Integer;
rating : Decimal;
} }
// Input validation // Input validation

View File

@@ -12,11 +12,11 @@ module.exports = cds.service.impl (function(){
// Emit an event to inform subscribers about new avg ratings for reviewed subjects // Emit an event to inform subscribers about new avg ratings for reviewed subjects
this.after (['CREATE','UPDATE','DELETE'], 'Reviews', async function(_,req) { this.after (['CREATE','UPDATE','DELETE'], 'Reviews', async function(_,req) {
const {subject} = req.data const {subject} = req.data
const {rating} = await cds.tx(req) .run ( const { count, rating } = await cds.tx(req) .run (
SELECT.one (['round(avg(rating),2) as rating']) .from (Reviews) .where ({subject}) SELECT.one `round(avg(rating),2) as rating, count(*) as count` .from (Reviews) .where ({subject})
) )
global.it || console.log ('< emitting:', 'reviewed', { subject, rating }) global.it || console.log ('< emitting:', 'reviewed', { subject, count, rating })
await this.emit ('reviewed', { subject, rating }) await this.emit ('reviewed', { subject, count, rating })
}) })
// Increment counter for reviews considered helpful // Increment counter for reviews considered helpful

View File

@@ -0,0 +1,3 @@
<head>
<meta http-equiv="refresh" content="0;url=app/bookshop/index.html">
</head>

View File

@@ -0,0 +1,3 @@
<head>
<meta http-equiv="refresh" content="0;url=app/reviews/index.html">
</head>

17
reviews/test/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@capire/bookshop-with-reviews",
"version": "1.0.0",
"dependencies": {
"@capire/bookshop": "*",
"@capire/reviews": "*",
"@sap/cds": "^5",
"express": "^4.17.1"
},
"cds": {
"requires": {
"db": {
"kind": "sql"
}
}
}
}

View File

@@ -0,0 +1,13 @@
// Use enhanced implementation for CatalogService
using { CatalogService } from '@capire/bookshop';
annotate CatalogService with @impl:'srv/bookshop.js';
// Extend Books with access to Reviews and average ratings
using { sap.capire.bookshop.Books } from '@capire/bookshop';
using { ReviewsService.Reviews } from '@capire/reviews';
extend Books with {
reviews : Composition of many Reviews on reviews.subject = $self.ID;
rating : Decimal;
numberOfReviews : Integer;
}

View File

@@ -0,0 +1,27 @@
const { CatalogService } = require('@capire/bookshop')
const cds = require ('@sap/cds')
module.exports = class extends CatalogService {async init(){
const { Books } = cds.entities('sap.capire.bookshop')
// Connect to ReviewsService to receive `reviewed` events from it
const ReviewsService = await cds.connect.to ('ReviewsService')
ReviewsService.on ('reviewed', (msg) => {
console.debug ('> received:', msg.event, msg.data)
const { subject, count, rating } = msg.data
return UPDATE(Books,subject).with({ numberOfReviews:count, rating })
})
return super.init()
}}
// -----------------------------------------------------------------------
// Helper for serving static content from npm-installed packages
const {dirname,resolve} = require('path')
const {static} = require('express')
cds.once('listening',()=>{
cds.app.use ('/app/bookshop', static (dirname (require.resolve('@capire/bookshop'))+'/app/vue'))
cds.app.use ('/app/reviews', static (resolve (__dirname, '../../app/vue')))
})

View File

@@ -86,6 +86,15 @@ describe('cds.ql → cqn', () => {
.to.eql(SELECT.from(Foo,{ID:11})) .to.eql(SELECT.from(Foo,{ID:11}))
.to.eql(SELECT.from(Foo).byKey(11)) .to.eql(SELECT.from(Foo).byKey(11))
.to.eql(SELECT.from(Foo).byKey({ID:11})) .to.eql(SELECT.from(Foo).byKey({ID:11}))
if (cds.version >= '5.6.0') {
expect.one(cqn)
.to.eql({
SELECT: {
one: true,
from: { ref: [{ id: 'Foo', where: [{ ref: ['ID'] }, '=', { val: 11 }] }] },
},
})
} else {
expect.one(cqn) expect.one(cqn)
.to.eql({ .to.eql({
SELECT: { SELECT: {
@@ -94,6 +103,7 @@ describe('cds.ql → cqn', () => {
where: [{ ref: ['ID'] }, '=', { val: 11 }], where: [{ ref: ['ID'] }, '=', { val: 11 }],
}, },
}) })
}
}) })
@@ -131,6 +141,17 @@ describe('cds.ql → cqn', () => {
// Test combination with key as second argument to .from // Test combination with key as second argument to .from
expect(cqn = SELECT.from(Foo, 11, ['a'])) expect(cqn = SELECT.from(Foo, 11, ['a']))
.to.eql(SELECT.from(Foo, 11, foo => foo.a)) .to.eql(SELECT.from(Foo, 11, foo => foo.a))
if (cds.version >= '5.6.0') {
expect.one(cqn)
.to.eql({
SELECT: {
one: true,
from: { ref: [{ id: 'Foo', where: [{ ref: ['ID'] }, '=', { val: 11 }]}] },
columns: [{ ref: ['a'] }]
},
})
} else {
expect.one(cqn) expect.one(cqn)
.to.eql({ .to.eql({
SELECT: { SELECT: {
@@ -140,6 +161,7 @@ describe('cds.ql → cqn', () => {
where: [{ ref: ['ID'] }, '=', { val: 11 }], where: [{ ref: ['ID'] }, '=', { val: 11 }],
}, },
}) })
}
}) })
@@ -551,21 +573,31 @@ describe('cds.ql → cqn', () => {
describe(`UPDATE...`, () => { describe(`UPDATE...`, () => {
test('entity (..., <key>)', () => { test('entity (..., <key>)', () => {
expect(UPDATE(Books, 4711)) const cqnWhere = {
.to.eql(UPDATE(Books, { ID: 4711 }))
.to.eql(UPDATE(Books).byKey(4711))
.to.eql(UPDATE(Books).byKey({ ID: 4711 }))
.to.eql(UPDATE(Books).where({ ID: 4711 }))
.to.eql(UPDATE(Books).where(`ID=`, 4711))
.to.eql(UPDATE.entity(Books, 4711))
.to.eql(UPDATE.entity(Books, { ID: 4711 }))
// etc...
.to.eql({
UPDATE: { UPDATE: {
entity: 'capire.bookshop.Books', entity: 'capire.bookshop.Books',
where: [{ ref: ['ID'] }, '=', { val: 4711 }], where: [{ ref: ['ID'] }, '=', { val: 4711 }],
}, },
}) }
expect(UPDATE(Books).where({ ID: 4711 }))
.to.eql(UPDATE(Books).where(`ID=`, 4711))
.to.eql(cqnWhere)
const cqnKey = (cds.version >= '5.6.0') ?
{
UPDATE: {
entity: { ref: [{ id: 'capire.bookshop.Books', where: [{ ref: ['ID'] }, '=', { val: 4711 }] }] }
}
}
: cqnWhere
expect(UPDATE(Books, 4711))
.to.eql(UPDATE(Books, { ID: 4711 }))
.to.eql(UPDATE(Books).byKey(4711))
.to.eql(UPDATE(Books).byKey({ ID: 4711 }))
.to.eql(UPDATE.entity(Books, 4711))
.to.eql(UPDATE.entity(Books, { ID: 4711 }))
// etc...
.to.eql(cqnKey)
}) })
/* /*
@@ -616,20 +648,29 @@ describe('cds.ql → cqn', () => {
describe(`DELETE...`, () => { describe(`DELETE...`, () => {
test('from (..., <key>)', () => { test('from (..., <key>)', () => {
const cqnWhere = {
DELETE: {
from: 'capire.bookshop.Books',
where: [{ ref: ['ID'] }, '=', { val: 4711 }],
},
}
expect(DELETE.from(Books).where({ ID: 4711 }))
.to.eql(DELETE.from(Books).where(`ID=`, 4711))
.to.eql(cqnWhere)
const cqnKey = (cds.version >= '5.6.0') ?
{
DELETE: {
from: { ref: [{ id: 'capire.bookshop.Books', where: [{ ref: ['ID'] }, '=', { val: 4711 }]}] }
},
} : cqnWhere
expect(DELETE(Books, 4711)) expect(DELETE(Books, 4711))
.to.eql(DELETE(Books, { ID: 4711 })) .to.eql(DELETE(Books, { ID: 4711 }))
.to.eql(DELETE.from(Books, 4711)) .to.eql(DELETE.from(Books, 4711))
.to.eql(DELETE.from(Books, { ID: 4711 })) .to.eql(DELETE.from(Books, { ID: 4711 }))
.to.eql(DELETE.from(Books).byKey(4711)) .to.eql(DELETE.from(Books).byKey(4711))
.to.eql(DELETE.from(Books).byKey({ ID: 4711 })) .to.eql(DELETE.from(Books).byKey({ ID: 4711 }))
.to.eql(DELETE.from(Books).where({ ID: 4711 })) .to.eql(cqnKey)
.to.eql(DELETE.from(Books).where(`ID=`, 4711))
.to.eql({
DELETE: {
from: 'capire.bookshop.Books',
where: [{ ref: ['ID'] }, '=', { val: 4711 }],
},
})
}) })
test('/w plain SQL', () => { test('/w plain SQL', () => {

View File

@@ -60,11 +60,11 @@ describe('Messaging', ()=>{
expect(M).equals(N) expect(M).equals(N)
expect(received.length).equals(N) expect(received.length).equals(N)
expect(received.map(m=>m.data)).to.deep.equal([ expect(received.map(m=>m.data)).to.deep.equal([
{ subject: '201', rating: 1 }, { count: 1, subject: '201', rating: 1 },
{ subject: '201', rating: 1.5 }, { count: 2, subject: '201', rating: 1.5 },
{ subject: '201', rating: 2 }, { count: 3, subject: '201', rating: 2 },
{ subject: '201', rating: 2.5 }, { count: 4, subject: '201', rating: 2.5 },
{ subject: '201', rating: 3 }, { count: 5, subject: '201', rating: 3 },
]) ])
}) })
}) })

54
test/registry.test.js Normal file
View File

@@ -0,0 +1,54 @@
const { fork } = require('child_process')
const { resolve } = require('path')
const Axios = require('axios')
const verbose = process.env.CDS_TEST_VERBOSE
// ||true
describe('Local NPM registry', () => {
let registry
let axios
const cwd = resolve(__dirname, '..')
beforeAll(async ()=> {
const env = Object.assign(process.env, {PORT:'0'})
const res = await exec (resolve(cwd, '.registry/server.js'), {cwd, stdio: 'pipe', env})
registry = res.cp
axios = Axios.default.create ({ baseURL: res.url, validateStatus: (status)=>status<500 })
})
afterAll(() => { registry.kill() })
for (const mod of ['bookshop','fiori','orders','reviews']) {
it(`should serve ${mod}`, async () => {
const resp = await axios.get(`/@capire/${mod}`)
expect(resp.data).toMatchObject({name: `@capire/${mod}`, versions:{}})
const versions = Object.values(resp.data.versions)
await axios.get(versions[0].dist.tarball)
})
}
it(`should return 404 for unknown packages`, async () => {
let resp = await axios.get(`/@capire/foo`)
expect(resp.status).toEqual(404)
resp = await axios.get(`/foo`)
expect(resp.status).toEqual(404)
})
})
function exec (script, opts) {
return new Promise((resolve, reject)=> {
const cp = fork (script, [], opts)
.on('error', err => reject(new Error(err)))
cp.stdout.on('data', chunk => {
if (verbose) console.log(chunk.toString())
if (chunk.toString().match(/listening.*(http:.*:\d+)/i)) {
resolve({cp, url:RegExp.$1})
}
})
cp.stderr.on('data', chunk => {
if (verbose) console.error(chunk.toString())
})
})
}