├── .npmrc
├── .github
├── CODEOWNERS
├── dependabot.yml
└── workflows
│ ├── label-issues.yml
│ ├── ci.yml
│ └── release.yml
├── .prettierignore
├── jest.config.js
├── test
├── resources
│ ├── empty-csn-definitions
│ │ ├── srv
│ │ │ ├── empty-service.cds
│ │ │ ├── empty-entity.cds
│ │ │ └── empty-aspect.cds
│ │ └── package.json
│ ├── error-handling
│ │ ├── i18n
│ │ │ ├── messages_en.properties
│ │ │ └── messages_de.properties
│ │ ├── package.json
│ │ └── srv
│ │ │ ├── custom-handler-errors.cds
│ │ │ ├── assertion-errors.cds
│ │ │ └── custom-handler-errors.js
│ ├── types
│ │ ├── db
│ │ │ ├── data
│ │ │ │ └── test.jpg
│ │ │ └── schema.cds
│ │ ├── srv
│ │ │ └── types.cds
│ │ └── package.json
│ ├── bookshop
│ │ ├── index.js
│ │ ├── test
│ │ │ ├── genres.cds
│ │ │ ├── genres.http
│ │ │ └── requests.http
│ │ ├── index.cds
│ │ ├── srv
│ │ │ ├── admin-service.cds
│ │ │ ├── user-service.cds
│ │ │ ├── user-service.js
│ │ │ ├── admin-service.js
│ │ │ ├── cat-service.cds
│ │ │ └── cat-service.js
│ │ ├── db
│ │ │ ├── data
│ │ │ │ ├── sap.capire.bookshop-Genres.csv
│ │ │ │ ├── sap.capire.bookshop-Authors.csv
│ │ │ │ ├── sap.capire.bookshop-Books_texts.csv
│ │ │ │ └── sap.capire.bookshop-Books.csv
│ │ │ ├── init.js
│ │ │ └── schema.cds
│ │ ├── package.json
│ │ ├── readme.md
│ │ └── app
│ │ │ └── vue
│ │ │ ├── app.js
│ │ │ └── index.html
│ ├── special-chars
│ │ ├── srv
│ │ │ ├── service.cds
│ │ │ ├── entity.cds
│ │ │ └── element.cds
│ │ └── package.json
│ ├── bookshop-graphql
│ │ ├── srv
│ │ │ ├── cat-service.cds
│ │ │ ├── test-service.cds
│ │ │ ├── test-service.js
│ │ │ └── admin-service.cds
│ │ ├── db
│ │ │ └── schema.cds
│ │ └── package.json
│ ├── cds.Request
│ │ ├── srv
│ │ │ ├── request.cds
│ │ │ └── request.js
│ │ └── package.json
│ ├── custom-error-formatter
│ │ ├── srv
│ │ │ ├── custom-error-formatter.cds
│ │ │ └── custom-error-formatter.js
│ │ ├── package.json
│ │ └── customErrorFormatter.js
│ ├── concurrency
│ │ ├── package.json
│ │ └── srv
│ │ │ ├── concurrency.cds
│ │ │ ├── data-and-errors.cds
│ │ │ ├── concurrency.js
│ │ │ └── data-and-errors.js
│ ├── edge-cases
│ │ ├── package.json
│ │ └── srv
│ │ │ ├── fields-with-connection-names.cds
│ │ │ └── field-named-localized.cds
│ ├── index.js
│ ├── custom-handlers
│ │ ├── server.js
│ │ ├── package.json
│ │ └── srv
│ │ │ ├── return-types.js
│ │ │ └── return-types.cds
│ ├── annotations
│ │ ├── package.json
│ │ └── srv
│ │ │ └── protocols.cds
│ ├── model-structure
│ │ └── srv
│ │ │ └── composition-of-aspect.cds
│ └── models.json
├── schemas
│ ├── empty-csn-definitions
│ │ ├── service.gql
│ │ ├── aspect.gql
│ │ └── entity.gql
│ ├── model-structure
│ │ └── composition-of-aspect.gql
│ └── edge-cases
│ │ ├── field-named-localized.gql
│ │ └── fields-with-connection-names.gql
├── tests
│ ├── empty-query-root-type.test.js
│ ├── invalid-schema.test.js
│ ├── http.test.js
│ ├── schema.test.js
│ ├── graphiql.test.js
│ ├── custom-error-formatter.test.js
│ ├── edge-cases.test.js
│ ├── context.test.js
│ ├── request.test.js
│ ├── annotations.test.js
│ ├── queries
│ │ ├── paging-offset.test.js
│ │ └── orderBy.test.js
│ └── concurrency.test.js
├── scripts
│ └── generate-schemas.js
└── util
│ └── index.js
├── lib
├── resolvers
│ ├── query.js
│ ├── parse
│ │ ├── ast
│ │ │ ├── index.js
│ │ │ ├── variable.js
│ │ │ ├── fragment.js
│ │ │ ├── fromObject.js
│ │ │ ├── enrich.js
│ │ │ ├── result.js
│ │ │ └── literal.js
│ │ ├── ast2cqn
│ │ │ ├── limit.js
│ │ │ ├── utils
│ │ │ │ └── index.js
│ │ │ ├── index.js
│ │ │ ├── orderBy.js
│ │ │ ├── entries.js
│ │ │ ├── columns.js
│ │ │ └── where.js
│ │ └── util
│ │ │ └── index.js
│ ├── utils
│ │ └── index.js
│ ├── index.js
│ ├── crud
│ │ ├── index.js
│ │ ├── create.js
│ │ ├── utils
│ │ │ └── index.js
│ │ ├── delete.js
│ │ ├── read.js
│ │ └── update.js
│ ├── GraphQLRequest.js
│ ├── response.js
│ ├── mutation.js
│ ├── field.js
│ ├── root.js
│ └── error.js
├── utils
│ └── index.js
├── schema
│ ├── types
│ │ ├── custom
│ │ │ ├── GraphQLVoid.js
│ │ │ ├── index.js
│ │ │ ├── GraphQLDate.js
│ │ │ ├── GraphQLDateTime.js
│ │ │ ├── GraphQLTime.js
│ │ │ ├── GraphQLInt16.js
│ │ │ ├── GraphQLUInt8.js
│ │ │ ├── GraphQLTimestamp.js
│ │ │ ├── GraphQLBinary.js
│ │ │ ├── util
│ │ │ │ └── index.js
│ │ │ ├── GraphQLDecimal.js
│ │ │ └── GraphQLInt64.js
│ │ ├── scalar.js
│ │ └── object.js
│ ├── util
│ │ └── index.js
│ ├── args
│ │ ├── index.js
│ │ ├── orderBy.js
│ │ ├── input.js
│ │ └── filter.js
│ ├── index.js
│ ├── query.js
│ └── mutation.js
├── api.js
├── compile.js
├── errorFormatter.js
├── GraphQLAdapter.js
├── constants.js
└── logger.js
├── .gitignore
├── cds-plugin.js
├── app
├── graphiql.js
└── graphiql.html
├── .prettierrc.js
├── index.js
├── .eslintrc
├── package.json
├── REUSE.toml
├── CONTRIBUTING.md
├── README.md
└── CODE_OF_CONDUCT.md
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @cap-js/node-js-runtime
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | test/resources
2 | test/schemas
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | testTimeout: 10000
3 | }
4 |
5 | module.exports = config
6 |
--------------------------------------------------------------------------------
/test/resources/empty-csn-definitions/srv/empty-service.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service EmptyService {}
3 |
--------------------------------------------------------------------------------
/lib/resolvers/query.js:
--------------------------------------------------------------------------------
1 | const { executeRead } = require('./crud')
2 |
3 | module.exports = executeRead
4 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast/index.js:
--------------------------------------------------------------------------------
1 | const enrichAST = require('./enrich')
2 |
3 | module.exports = { enrichAST }
4 |
--------------------------------------------------------------------------------
/test/resources/error-handling/i18n/messages_en.properties:
--------------------------------------------------------------------------------
1 | ORDER_EXCEEDS_STOCK=The order of {0} books exceeds the stock by {1}
--------------------------------------------------------------------------------
/test/resources/types/db/data/test.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cap-js/graphql/HEAD/test/resources/types/db/data/test.jpg
--------------------------------------------------------------------------------
/test/resources/bookshop/index.js:
--------------------------------------------------------------------------------
1 | const { CatalogService } = require('./srv/cat-service')
2 | module.exports = { CatalogService }
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | package-lock.json
3 | .idea
4 | .DS_Store
5 | .vscode
6 | **/_out
7 |
8 | # cds-typer
9 | @cds-models/
10 |
--------------------------------------------------------------------------------
/test/resources/special-chars/srv/service.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service SpecialCharsÄÖÜService {
3 | entity Root {
4 | key ID : UUID;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/test/resources/bookshop-graphql/srv/cat-service.cds:
--------------------------------------------------------------------------------
1 | using {CatalogService} from '../../bookshop/srv/cat-service';
2 | annotate CatalogService with @graphql;
3 |
--------------------------------------------------------------------------------
/test/resources/special-chars/srv/entity.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service SpecialCharsEntityService {
3 | entity RootÄÖÜEntity {
4 | key ID : UUID;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/test/resources/bookshop-graphql/srv/test-service.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service TestService {
3 | entity Foo {
4 | key ID : Integer;
5 | bar : String;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/test/resources/cds.Request/srv/request.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service RequestService {
3 | entity A {
4 | key id : UUID;
5 | my_header : String;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/test/resources/custom-error-formatter/srv/custom-error-formatter.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service CustomErrorFormatterService {
3 | entity A {
4 | key ID : UUID;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/test/resources/bookshop/test/genres.cds:
--------------------------------------------------------------------------------
1 | using { sap.capire.bookshop as my } from '../db/schema';
2 | service TestService {
3 | entity Genres as projection on my.Genres;
4 | }
5 |
--------------------------------------------------------------------------------
/test/resources/cds.Request/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/test/resources/concurrency/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/test/resources/edge-cases/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/test/resources/special-chars/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/test/resources/empty-csn-definitions/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/test/resources/error-handling/i18n/messages_de.properties:
--------------------------------------------------------------------------------
1 | MULTIPLE_ERRORS=Es sind mehrere Fehler aufgetreten.
2 | ASSERT_NOT_NULL=Wert ist erforderlich
3 |
4 | MY_CODE=Mein custom Fehlercode
--------------------------------------------------------------------------------
/test/resources/types/srv/types.cds:
--------------------------------------------------------------------------------
1 |
2 | using { sap.cds.graphql.types as my } from '../db/schema';
3 | @graphql
4 | service TypesService {
5 | entity MyEntity as projection on my.MyEntity;
6 | }
--------------------------------------------------------------------------------
/test/resources/bookshop/index.cds:
--------------------------------------------------------------------------------
1 | namespace sap.capire.bookshop; //> important for reflection
2 | using from './db/schema';
3 | using from './srv/cat-service';
4 | using from './srv/admin-service';
5 |
--------------------------------------------------------------------------------
/test/resources/special-chars/srv/element.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service SpecialCharsElementService {
3 | entity Root {
4 | key ID : UUID;
5 | myÄÖÜElement : String;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 |
5 | - package-ecosystem: npm
6 | directory: /
7 | versioning-strategy: increase-if-necessary
8 | schedule:
9 | interval: daily
10 |
--------------------------------------------------------------------------------
/lib/resolvers/utils/index.js:
--------------------------------------------------------------------------------
1 | const isPlainObject = value =>
2 | value !== null && typeof value === 'object' && !Array.isArray(value) && !Buffer.isBuffer(value)
3 |
4 | module.exports = { isPlainObject }
5 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast2cqn/limit.js:
--------------------------------------------------------------------------------
1 | const astToLimit = (topArg, skipArg) => ({
2 | rows: { val: topArg.value.value },
3 | offset: { val: skipArg?.value.value || 0 }
4 | })
5 |
6 | module.exports = astToLimit
7 |
--------------------------------------------------------------------------------
/test/schemas/empty-csn-definitions/service.gql:
--------------------------------------------------------------------------------
1 | type Query {
2 | _: Void
3 | }
4 |
5 | """
6 | The `Void` scalar type represents the absence of a value. Void can only represent the value `null`.
7 | """
8 | scalar Void
--------------------------------------------------------------------------------
/lib/resolvers/index.js:
--------------------------------------------------------------------------------
1 | const registerAliasFieldResolvers = require('./field')
2 | const createRootResolvers = require('./root')
3 |
4 | module.exports = {
5 | registerAliasFieldResolvers,
6 | createRootResolvers
7 | }
8 |
--------------------------------------------------------------------------------
/lib/utils/index.js:
--------------------------------------------------------------------------------
1 | const IS_PRODUCTION = process.env.NODE_ENV === 'production' || process.env.CDS_ENV === 'prod'
2 |
3 | const gqlName = cdsName => cdsName.replace(/\./g, '_')
4 |
5 | module.exports = { IS_PRODUCTION, gqlName }
6 |
--------------------------------------------------------------------------------
/test/resources/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 |
3 | const models = require('./models').map(model => ({
4 | ...model,
5 | files: model.files.map(file => path.join(__dirname, file))
6 | }))
7 |
8 | module.exports = { models }
9 |
--------------------------------------------------------------------------------
/test/resources/custom-handlers/server.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | // Programmatic configuration of GraphQL protocol adapter
3 | const protocols = cds.env.protocols ??= {}
4 | protocols.graphql = { path: '/graphql', impl: '@cap-js/graphql' }
--------------------------------------------------------------------------------
/test/resources/bookshop/srv/admin-service.cds:
--------------------------------------------------------------------------------
1 | using { sap.capire.bookshop as my } from '../db/schema';
2 | service AdminService @(requires:'admin') {
3 | entity Books as projection on my.Books;
4 | entity Authors as projection on my.Authors;
5 | }
6 |
--------------------------------------------------------------------------------
/test/resources/concurrency/srv/concurrency.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service ConcurrencyService {
3 | entity A {
4 | key id : UUID;
5 | }
6 |
7 | entity B {
8 | key id : UUID;
9 | }
10 |
11 | entity C {
12 | key id : UUID;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/test/resources/custom-handlers/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "Note: Programmatic configuration of GraphQL protocol adapter in server.js": "",
3 | "__dependencies": {
4 | "@cap-js/graphql": "*"
5 | },
6 | "devDependencies": {
7 | "@cap-js/sqlite": "*"
8 | }
9 | }
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast2cqn/utils/index.js:
--------------------------------------------------------------------------------
1 | const { Kind } = require('graphql')
2 |
3 | const getArgumentByName = (args, name) =>
4 | args.find(arg => arg.value && arg.name.value === name && arg.value.kind !== Kind.NULL)
5 |
6 | module.exports = { getArgumentByName }
7 |
--------------------------------------------------------------------------------
/lib/resolvers/crud/index.js:
--------------------------------------------------------------------------------
1 | const executeCreate = require('./create')
2 | const executeRead = require('./read')
3 | const executeUpdate = require('./update')
4 | const executeDelete = require('./delete')
5 |
6 | module.exports = { executeCreate, executeRead, executeUpdate, executeDelete }
7 |
--------------------------------------------------------------------------------
/cds-plugin.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | require('./lib/api').registerCompileTargets()
3 | const defaults = { path: '/graphql', impl: '@cap-js/graphql' }
4 | const protocols = cds.env.protocols ??= {}
5 | protocols.graphql ??= {}
6 | protocols.graphql = { ...defaults, ...protocols.graphql}
7 |
--------------------------------------------------------------------------------
/lib/resolvers/GraphQLRequest.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 |
3 | class GraphQLRequest extends cds.Request {
4 | constructor(args) {
5 | super(args)
6 | Object.defineProperty(this, 'protocol', { value: 'graphql' })
7 | }
8 | }
9 |
10 | module.exports = GraphQLRequest
11 |
--------------------------------------------------------------------------------
/test/resources/bookshop-graphql/srv/test-service.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 |
3 | module.exports = cds.service.impl(srv => {
4 | const { Foo } = srv.entities
5 | // dummy read to prove that same tx is used (= no blocked tx on sqlite)
6 | srv.before('*', () => SELECT.from(Foo))
7 | })
8 |
--------------------------------------------------------------------------------
/test/resources/empty-csn-definitions/srv/empty-entity.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service EmptyEntityService {
3 | entity NonEmptyEntity {
4 | key ID : UUID;
5 | }
6 |
7 | entity EmptyEntity {}
8 | }
9 |
10 | @graphql
11 | service WillBecomeEmptyService {
12 | entity EmptyEntity {}
13 | }
14 |
--------------------------------------------------------------------------------
/test/resources/empty-csn-definitions/srv/empty-aspect.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service EmptyAspectService {
3 | entity Root {
4 | key ID : UUID;
5 | emptyAspect : Composition of EmptyAspect;
6 | emptyAspects : Composition of many EmptyAspect;
7 | }
8 |
9 | aspect EmptyAspect {}
10 | }
11 |
--------------------------------------------------------------------------------
/lib/resolvers/response.js:
--------------------------------------------------------------------------------
1 | const { handleCDSError } = require('./error')
2 |
3 | const setResponse = async (context, response, key, value) => {
4 | try {
5 | response[key] = await value
6 | } catch (error) {
7 | response[key] = handleCDSError(context, error)
8 | }
9 | }
10 |
11 | module.exports = { setResponse }
12 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast/variable.js:
--------------------------------------------------------------------------------
1 | const objectToAST = require('./fromObject')
2 |
3 | const _getVariableValueForVariable = (info, variable) => info.variableValues[variable.name.value]
4 |
5 | const substituteVariable = (info, variable) => objectToAST(_getVariableValueForVariable(info, variable))
6 |
7 | module.exports = substituteVariable
8 |
--------------------------------------------------------------------------------
/test/resources/bookshop/srv/user-service.cds:
--------------------------------------------------------------------------------
1 | /**
2 | * Exposes user information
3 | */
4 | service UserService {
5 | /**
6 | * The current user
7 | */
8 | @odata.singleton entity me {
9 | id : String; // user id
10 | locale : String;
11 | tenant : String;
12 | }
13 |
14 | action login() returns me;
15 | }
16 |
--------------------------------------------------------------------------------
/test/resources/annotations/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | },
8 | "cds": {
9 | "protocols": {
10 | "graphql": {
11 | "path": "/custom-graphql-path",
12 | "graphiql": false
13 | }
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/test/resources/bookshop-graphql/srv/admin-service.cds:
--------------------------------------------------------------------------------
1 | using {sap.capire.graphql} from '../db/schema';
2 | using {AdminService} from '../../bookshop/srv/admin-service';
3 |
4 | @graphql
5 | /** Service used by administrators to manage Books and Authors */
6 | extend service AdminService with {
7 | entity Chapters as projection on graphql.Chapters;
8 | }
9 |
--------------------------------------------------------------------------------
/test/resources/custom-handlers/srv/return-types.js:
--------------------------------------------------------------------------------
1 | module.exports = srv => {
2 | const string = 'foo'
3 | const id = '0557a188-326e-4dcb-999b-e1acf7979fa3'
4 |
5 | srv.on('*', 'Integer', () => 999)
6 | srv.on('*', 'String', () => string)
7 | srv.on('*', 'Object', () => ({ id, string }))
8 | srv.on('*', 'Array', () => [{ id, string }, { id, string }])
9 | }
--------------------------------------------------------------------------------
/test/resources/types/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | },
8 | "cds": {
9 | "server": {
10 | "body_parser": {
11 | "limit": "110KB"
12 | }
13 | },
14 | "features": {
15 | "ieee754compatible": true
16 | }
17 | }
18 | }
--------------------------------------------------------------------------------
/test/resources/bookshop/db/data/sap.capire.bookshop-Genres.csv:
--------------------------------------------------------------------------------
1 | ID;parent_ID;name
2 | 10;;Fiction
3 | 11;10;Drama
4 | 12;10;Poetry
5 | 13;10;Fantasy
6 | 14;10;Science Fiction
7 | 15;10;Romance
8 | 16;10;Mystery
9 | 17;10;Thriller
10 | 18;10;Dystopia
11 | 19;10;Fairy Tale
12 | 20;;Non-Fiction
13 | 21;20;Biography
14 | 22;21;Autobiography
15 | 23;20;Essay
16 | 24;20;Speech
17 |
--------------------------------------------------------------------------------
/test/resources/custom-error-formatter/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | },
8 | "cds": {
9 | "protocols": {
10 | "graphql": {
11 | "path": "/graphql",
12 | "errorFormatter": "customErrorFormatter"
13 | }
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/test/resources/concurrency/srv/data-and-errors.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service DataAndErrorsService {
3 | entity A {
4 | key id : UUID;
5 | timestamp : Timestamp;
6 | }
7 |
8 | entity B {
9 | key id : UUID;
10 | timestamp : Timestamp;
11 | }
12 |
13 | entity C {
14 | key id : UUID;
15 | timestamp : Timestamp;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/test/resources/edge-cases/srv/fields-with-connection-names.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service FieldsWithConnectionNamesService {
3 | entity Root {
4 | key ID : UUID;
5 | nodes : Composition of Nodes;
6 | totalCount : String;
7 | }
8 |
9 | entity Nodes {
10 | key ID : UUID;
11 | nodes : String;
12 | totalCount : String;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast2cqn/index.js:
--------------------------------------------------------------------------------
1 | const { getArgumentByName } = require('./utils')
2 | const astToColumns = require('./columns')
3 | const astToWhere = require('./where')
4 | const astToOrderBy = require('./orderBy')
5 | const astToLimit = require('./limit')
6 | const astToEntries = require('./entries')
7 |
8 | module.exports = { getArgumentByName, astToColumns, astToWhere, astToOrderBy, astToLimit, astToEntries }
9 |
--------------------------------------------------------------------------------
/test/resources/model-structure/srv/composition-of-aspect.cds:
--------------------------------------------------------------------------------
1 | @protocol: ['graphql']
2 | service CompositionOfAspectService {
3 | entity Books {
4 | key id : UUID;
5 | chapters : Composition of many Chapters;
6 | reviews : Composition of many {
7 | key id : UUID;
8 | }
9 | }
10 |
11 | aspect Chapters {
12 | key id : UUID;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/test/resources/bookshop/srv/user-service.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | module.exports = class UserService extends cds.Service { init(){
3 | this.on('READ', 'me', ({ tenant, user, locale }) => ({ id: user.id, locale, tenant }))
4 | this.on('login', (req) => {
5 | if (req.user._is_anonymous)
6 | req._.res.set('WWW-Authenticate','Basic realm="Users"').sendStatus(401)
7 | else return this.read('me')
8 | })
9 | }}
10 |
--------------------------------------------------------------------------------
/test/resources/error-handling/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | },
8 | "cds": {
9 | "requires": {
10 | "db": {
11 | "kind": "sqlite",
12 | "credentials": {
13 | "database": ":memory:"
14 | }
15 | },
16 | "auth": {
17 | "kind": "mocked"
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/test/resources/bookshop/db/data/sap.capire.bookshop-Authors.csv:
--------------------------------------------------------------------------------
1 | ID;name;dateOfBirth;placeOfBirth;dateOfDeath;placeOfDeath
2 | 101;Emily Brontë;1818-07-30;Thornton, Yorkshire;1848-12-19;Haworth, Yorkshire
3 | 107;Charlotte Brontë;1818-04-21;Thornton, Yorkshire;1855-03-31;Haworth, Yorkshire
4 | 150;Edgar Allen Poe;1809-01-19;Boston, Massachusetts;1849-10-07;Baltimore, Maryland
5 | 170;Richard Carpenter;1929-08-14;King’s Lynn, Norfolk;2012-02-26;Hertfordshire, England
--------------------------------------------------------------------------------
/test/resources/bookshop/srv/admin-service.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 |
3 | module.exports = cds.service.impl (function(){
4 | this.before ('NEW','Authors', genid)
5 | this.before ('NEW','Books', genid)
6 | })
7 |
8 | /** Generate primary keys for target entity in request */
9 | async function genid (req) {
10 | const {ID} = await SELECT.one.from(req.target).columns('max(ID) as ID')
11 | req.data.ID = ID - ID % 100 + 100 + 1
12 | }
13 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLVoid.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType } = require('graphql')
2 |
3 | const serialize = () => null
4 |
5 | const parseValue = () => null
6 |
7 | const parseLiteral = () => null
8 |
9 | module.exports = new GraphQLScalarType({
10 | name: 'Void',
11 | description: 'The `Void` scalar type represents the absence of a value. Void can only represent the value `null`.',
12 | serialize,
13 | parseValue,
14 | parseLiteral
15 | })
16 |
--------------------------------------------------------------------------------
/app/graphiql.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const express = require('express')
3 | const router = express.Router()
4 | const fs = require('node:fs')
5 |
6 | const html = fs.readFileSync(path.join(__dirname, 'graphiql.html'))
7 |
8 | router.get('/', (req, res, next) => {
9 | // Forward GET requests with query URL parameter to GraphQL server
10 | if (req.query.query) return next()
11 | return res.type('html').send(html)
12 | })
13 |
14 | module.exports = router
15 |
--------------------------------------------------------------------------------
/test/resources/custom-error-formatter/customErrorFormatter.js:
--------------------------------------------------------------------------------
1 | let _count = 0
2 |
3 | module.exports = error => {
4 | const message = 'Oops! ' + error.message
5 | const custom = 'This property is added by the custom error formatter'
6 | const count = _count++
7 | const details = error.details // Exists if this is an outer error of multiple errors
8 |
9 | // Return a copy of the error with the desired formatting
10 | return { message, custom, count, details}
11 | }
--------------------------------------------------------------------------------
/test/resources/custom-handlers/srv/return-types.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service ReturnTypesService {
3 | entity Integer {
4 | key id : UUID;
5 | string : cds.String;
6 | }
7 |
8 | entity String {
9 | key id : UUID;
10 | string : cds.String;
11 | }
12 |
13 | entity Object {
14 | key id : UUID;
15 | string : cds.String;
16 | }
17 |
18 | entity Array {
19 | key id : UUID;
20 | string : cds.String;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/test/resources/custom-error-formatter/srv/custom-error-formatter.js:
--------------------------------------------------------------------------------
1 | module.exports = srv => {
2 | srv.on('READ', 'A', async req => {
3 | req.error({
4 | code: 'Some-Custom-Code1',
5 | message: 'Some Custom Error Message 1',
6 | target: 'some_field',
7 | status: 418
8 | })
9 | req.error({
10 | code: 'Some-Custom-Code2',
11 | message: 'Some Custom Error Message 2',
12 | target: 'some_field',
13 | status: 500
14 | })
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/test/resources/bookshop-graphql/db/schema.cds:
--------------------------------------------------------------------------------
1 | using {managed} from '@sap/cds/common';
2 | using {sap.capire.bookshop} from '../../bookshop/db/schema';
3 |
4 | namespace sap.capire.graphql;
5 |
6 | extend bookshop.Books with {
7 | chapters : Composition of many Chapters
8 | on chapters.book = $self;
9 | }
10 |
11 | /** A Chapter of a Book */
12 | entity Chapters : managed {
13 | key book : Association to bookshop.Books;
14 | key number : Integer;
15 | title : String;
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/label-issues.yml:
--------------------------------------------------------------------------------
1 | name: Label issues
2 | on:
3 | issues:
4 | types:
5 | - reopened
6 | - opened
7 | jobs:
8 | label_issues:
9 | runs-on: ubuntu-latest
10 | permissions:
11 | issues: write
12 | steps:
13 | - run: gh issue edit "$NUMBER" --add-label "$LABELS"
14 | env:
15 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
16 | GH_REPO: ${{ github.repository }}
17 | NUMBER: ${{ github.event.issue.number }}
18 | LABELS: new
--------------------------------------------------------------------------------
/lib/schema/types/custom/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GraphQLBinary: require('./GraphQLBinary'),
3 | GraphQLDate: require('./GraphQLDate'),
4 | GraphQLDateTime: require('./GraphQLDateTime'),
5 | GraphQLDecimal: require('./GraphQLDecimal'),
6 | GraphQLInt16: require('./GraphQLInt16'),
7 | GraphQLInt64: require('./GraphQLInt64'),
8 | GraphQLTime: require('./GraphQLTime'),
9 | GraphQLTimestamp: require('./GraphQLTimestamp'),
10 | GraphQLUInt8: require('./GraphQLUInt8'),
11 | GraphQLVoid: require('./GraphQLVoid')
12 | }
13 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "arrowParens": "avoid",
3 | "bracketSpacing": true,
4 | "embeddedLanguageFormatting": "auto",
5 | "endOfLine": "auto",
6 | "htmlWhitespaceSensitivity": "css",
7 | "insertPragma": false,
8 | "jsxSingleQuote": false,
9 | "printWidth": 120,
10 | "proseWrap": "preserve",
11 | "quoteProps": "as-needed",
12 | "requirePragma": false,
13 | "semi": false,
14 | "singleQuote": true,
15 | "tabWidth": 2,
16 | "trailingComma": "none",
17 | "useTabs": false,
18 | "vueIndentScriptAndStyle": false
19 | }
20 |
--------------------------------------------------------------------------------
/test/resources/cds.Request/srv/request.js:
--------------------------------------------------------------------------------
1 | module.exports = srv => {
2 | const my_res_header = 'my res header value'
3 |
4 | srv.on(['CREATE', 'READ', 'UPDATE'], 'A', req => {
5 | req.res?.header(Object.keys({my_res_header})[0], my_res_header)
6 | const { my_header } = req.headers
7 | return [{ id: 'df81ea80-bbff-479a-bc25-8eb16efbfaec', my_header }]
8 | })
9 |
10 | srv.on('DELETE', 'A', req => {
11 | req.res?.header(Object.keys({my_res_header})[0], my_res_header)
12 | const { my_header } = req.headers
13 | return my_header ? 999 : 0
14 | })
15 | }
--------------------------------------------------------------------------------
/test/resources/bookshop-graphql/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@cap-js/graphql": "*"
4 | },
5 | "devDependencies": {
6 | "@cap-js/sqlite": "*"
7 | },
8 | "cds": {
9 | "requires": {
10 | "db": {
11 | "kind": "sqlite",
12 | "impl": "@cap-js/sqlite",
13 | "credentials": {
14 | "database": ":memory:"
15 | }
16 | },
17 | "auth": {
18 | "kind": "mocked"
19 | }
20 | },
21 | "features": {
22 | "in_memory_db": true
23 | },
24 | "cdsc": {
25 | "docs": true
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast/fragment.js:
--------------------------------------------------------------------------------
1 | const { Kind } = require('graphql')
2 |
3 | const _getFragmentDefinitionForFragmentSpread = (info, fragmentSpread) => info.fragments[fragmentSpread.name.value]
4 |
5 | const _substituteFragment = (info, fragmentSpread) =>
6 | _getFragmentDefinitionForFragmentSpread(info, fragmentSpread).selectionSet.selections
7 |
8 | const fragmentSpreadSelections = (info, selections) =>
9 | selections.flatMap(selection =>
10 | selection.kind === Kind.FRAGMENT_SPREAD ? _substituteFragment(info, selection) : selection
11 | )
12 |
13 | module.exports = fragmentSpreadSelections
14 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const DEBUG = cds.debug('adapters')
3 | const GraphQLAdapter = require('./lib/GraphQLAdapter')
4 |
5 | let services
6 | const collectServicesAndMountAdapter = (srv, options) => {
7 | if (!services) {
8 | services = {}
9 | cds.on('served', () => {
10 | options.services = services
11 | cds.app.use (options.path, cds.middlewares.before, GraphQLAdapter(options), cds.middlewares.after)
12 | DEBUG?.('app.use(', options.path, ', ... )')
13 | })
14 | }
15 | services[srv.name] = srv
16 | }
17 |
18 | module.exports = collectServicesAndMountAdapter
19 |
--------------------------------------------------------------------------------
/test/resources/error-handling/srv/custom-handler-errors.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service CustomHandlerErrorsService {
3 | entity A {
4 | key id : Integer;
5 | }
6 |
7 | entity B {
8 | key id : Integer;
9 | }
10 |
11 | entity C {
12 | key id : Integer;
13 | }
14 |
15 | entity D {
16 | key id : Integer;
17 | }
18 |
19 | entity E {
20 | key id : Integer;
21 | }
22 |
23 | entity F {
24 | key id : Integer;
25 | }
26 |
27 | entity G {
28 | key id : Integer;
29 | }
30 |
31 | entity Orders {
32 | key id : Integer;
33 | stock : Integer;
34 | quantity : Integer;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "ignorePatterns": [
3 | "**/node_modules",
4 | "test/"
5 | ],
6 | "extends": "eslint:recommended",
7 | "env": {
8 | "es2020": true,
9 | "node": true,
10 | "jest": true,
11 | "mocha": true
12 | },
13 | "parserOptions": {
14 | "ecmaVersion": 2022
15 | },
16 | "globals": {
17 | "SELECT": true,
18 | "INSERT": true,
19 | "UPDATE": true,
20 | "DELETE": true,
21 | "CREATE": true,
22 | "DROP": true,
23 | "CDL": true,
24 | "CQL": true,
25 | "CXL": true
26 | },
27 | "rules": {
28 | "no-unused-vars": ["warn", { "argsIgnorePattern": "lazy" }],
29 | "no-extra-semi": "off"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/test/resources/bookshop/srv/cat-service.cds:
--------------------------------------------------------------------------------
1 | using { sap.capire.bookshop as my } from '../db/schema';
2 | service CatalogService @(path:'/browse') {
3 |
4 | /** For displaying lists of Books */
5 | @readonly entity ListOfBooks as projection on Books
6 | excluding { descr };
7 |
8 | /** For display in details pages */
9 | @readonly entity Books as projection on my.Books { *,
10 | author.name as author
11 | } excluding { createdBy, modifiedBy };
12 |
13 | @requires: 'authenticated-user'
14 | action submitOrder ( book: Books:ID, quantity: Integer ) returns { stock: Integer };
15 | event OrderedBook : { book: Books:ID; quantity: Integer; buyer: String };
16 | }
17 |
--------------------------------------------------------------------------------
/test/resources/error-handling/srv/assertion-errors.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service ValidationErrorsService {
3 | entity A {
4 | key id : Integer;
5 | notEmptyI : Integer @mandatory;
6 | }
7 |
8 | entity B {
9 | key id : Integer;
10 | notEmptyI : Integer @mandatory;
11 | notEmptyS : String @mandatory;
12 | }
13 |
14 | entity C {
15 | key id : Integer;
16 | inRange : Integer @mandatory @assert.range: [
17 | 0,
18 | 3
19 | ];
20 | oneOfEnumValues : String @assert.range enum {
21 | high;
22 | medium;
23 | low;
24 | };
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/lib/resolvers/mutation.js:
--------------------------------------------------------------------------------
1 | const { executeCreate, executeUpdate, executeDelete } = require('./crud')
2 | const { setResponse } = require('./response')
3 |
4 | module.exports = async (context, service, entity, field) => {
5 | const response = {}
6 |
7 | for (const selection of field.selectionSet.selections) {
8 | const operation = selection.name.value
9 | const responseKey = selection.alias?.value || operation
10 |
11 | const executeOperation = { create: executeCreate, update: executeUpdate, delete: executeDelete }[operation]
12 | const value = executeOperation(context, service, entity, selection)
13 | await setResponse(context, response, responseKey, value)
14 | }
15 |
16 | return response
17 | }
18 |
--------------------------------------------------------------------------------
/test/resources/bookshop/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@capire/bookshop",
3 | "version": "1.0.0",
4 | "description": "A simple self-contained bookshop service.",
5 | "files": [
6 | "app",
7 | "srv",
8 | "db",
9 | "index.cds",
10 | "index.js"
11 | ],
12 | "dependencies": {
13 | "@cap-js/graphql": "*",
14 | "@sap/cds": ">=5.9",
15 | "express": "^4.17.1"
16 | },
17 | "devDependencies": {
18 | "@cap-js/sqlite": "*"
19 | },
20 | "scripts": {
21 | "genres": "cds serve test/genres.cds",
22 | "start": "cds run",
23 | "watch": "cds watch"
24 | },
25 | "cds": {
26 | "requires": {
27 | "db": {
28 | "kind": "sql"
29 | }
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/test/resources/edge-cases/srv/field-named-localized.cds:
--------------------------------------------------------------------------------
1 | @graphql
2 | service FieldNamedLocalizedService {
3 | entity Root {
4 | key ID : Integer;
5 | // The resulting GraphQL schema should contain a field named
6 | // "localized" since it is a user modelled association and not an
7 | // automatically generated association that points to translated texts
8 | localized : Association to many localized
9 | on localized.root = $self;
10 | }
11 |
12 | entity localized {
13 | key ID : Integer;
14 | root : Association to Root;
15 | localized : String; // to test that a property only named 'localized' is not confused with localized keyword
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/lib/api.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 |
3 | const TARGETS = ['gql', 'graphql']
4 |
5 | function _lazyRegisterCompileTargets() {
6 | const value = require('./compile')
7 | // Lazy load all compile targets if any of them is accessed
8 | // For example .to.gql was called -> load .to.gql and .to.graphql
9 | TARGETS.forEach(target => Object.defineProperty(this, target, { value }))
10 | return value
11 | }
12 |
13 | // Register gql and graphql as cds.compile.to targets
14 | const registerCompileTargets = () => {
15 | TARGETS.forEach(target =>
16 | Object.defineProperty(cds.compile.to, target, {
17 | get: _lazyRegisterCompileTargets,
18 | configurable: true
19 | })
20 | )
21 | }
22 |
23 | module.exports = { registerCompileTargets }
24 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast2cqn/orderBy.js:
--------------------------------------------------------------------------------
1 | const { Kind } = require('graphql')
2 |
3 | const _objectFieldToOrderBy = objectField => ({
4 | ref: [objectField.name.value],
5 | sort: objectField.value.value
6 | })
7 |
8 | // OrderBy objects are supposed to contain only a single field
9 | const _parseObjectValue = objectValue => _objectFieldToOrderBy(objectValue.fields[0])
10 |
11 | const _parseListValue = listValue => listValue.values.map(value => _parseObjectValue(value))
12 |
13 | const astToOrderBy = orderByArg => {
14 | const value = orderByArg.value
15 | switch (value.kind) {
16 | case Kind.LIST:
17 | return _parseListValue(value)
18 | case Kind.OBJECT:
19 | return [_parseObjectValue(value)]
20 | }
21 | }
22 |
23 | module.exports = astToOrderBy
24 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/util/index.js:
--------------------------------------------------------------------------------
1 | const _filterOutDuplicateColumnsSelections = selections => {
2 | const mergedSelectionsMap = new Map()
3 |
4 | for (const selection of selections) {
5 | if (!selection.selectionSet) continue
6 | for (const field of selection.selectionSet.selections) {
7 | const key = field.alias?.value || field.name.value
8 | const fieldFromMap = mergedSelectionsMap.get(key)
9 | if (!fieldFromMap) mergedSelectionsMap.set(key, field)
10 | }
11 | }
12 |
13 | return Array.from(mergedSelectionsMap.values())
14 | }
15 |
16 | const getPotentiallyNestedNodesSelections = (selections, isConnection) =>
17 | isConnection ? _filterOutDuplicateColumnsSelections(selections) : selections
18 |
19 | module.exports = { getPotentiallyNestedNodesSelections }
20 |
--------------------------------------------------------------------------------
/test/resources/types/db/schema.cds:
--------------------------------------------------------------------------------
1 | namespace sap.cds.graphql.types; //> important for reflection
2 |
3 | entity MyEntity {
4 | myBinary : Binary;
5 | myBoolean : Boolean;
6 | myDate : Date;
7 | myDateTime : DateTime;
8 | myDecimal : Decimal;
9 | myDecimalFloat : DecimalFloat;
10 | myDouble : Double;
11 | myInt16 : Int16;
12 | myInt32 : Int32;
13 | myInt64 : Int64;
14 | myInteger : Integer;
15 | myInteger64 : Integer64;
16 | myLargeBinary : LargeBinary;
17 | myLargeString : LargeString;
18 | myString : String;
19 | myTime : Time;
20 | myTimestamp : Timestamp;
21 | myUInt8 : UInt8;
22 | key myUUID : UUID;
23 | }
24 |
--------------------------------------------------------------------------------
/lib/resolvers/field.js:
--------------------------------------------------------------------------------
1 | const { isObjectType, isIntrospectionType } = require('graphql')
2 |
3 | // The GraphQL.js defaultFieldResolver does not support returning aliased values that resolve to fields with aliases
4 | function aliasFieldResolver(source, args, contextValue, info) {
5 | const responseKey = info.fieldNodes[0].alias ? info.fieldNodes[0].alias.value : info.fieldName
6 | return source?.[responseKey]
7 | }
8 |
9 | const registerAliasFieldResolvers = schema => {
10 | for (const type of Object.values(schema.getTypeMap())) {
11 | if (!isObjectType(type) || isIntrospectionType(type)) continue
12 |
13 | for (const field of Object.values(type.getFields())) {
14 | if (!field.resolve) field.resolve = aliasFieldResolver
15 | }
16 | }
17 | }
18 |
19 | module.exports = registerAliasFieldResolvers
20 |
--------------------------------------------------------------------------------
/test/tests/empty-query-root-type.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - empty query root operation type', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../util')
5 |
6 | const { axios, POST } = cds
7 | .test('serve', 'srv/empty-service.cds')
8 | .in(path.join(__dirname, '../resources/empty-csn-definitions'))
9 | // Prevent axios from throwing errors for non 2xx status codes
10 | axios.defaults.validateStatus = false
11 |
12 | test('_ placeholder field of type Void returns null', async () => {
13 | const query = gql`
14 | {
15 | _
16 | }
17 | `
18 | const data = {
19 | _: null
20 | }
21 |
22 | const response = await POST('/graphql', { query })
23 | expect(response.data).toEqual({ data })
24 | })
25 | })
26 |
--------------------------------------------------------------------------------
/test/resources/bookshop/db/init.js:
--------------------------------------------------------------------------------
1 | /**
2 | * In order to keep basic bookshop sample as simple as possible, we don't add
3 | * reuse dependencies. This db/init.js ensures we still have a minimum set of
4 | * currencies, if not obtained through @capire/common.
5 | */
6 |
7 | module.exports = async (db)=>{
8 |
9 | const has_common = db.model.definitions['sap.common.Currencies'].elements.numcode
10 | if (has_common) return
11 |
12 | const already_filled = await db.exists('sap.common.Currencies',{code:'EUR'})
13 | if (already_filled) return
14 |
15 | await INSERT.into ('sap.common.Currencies') .columns (
16 | 'code','symbol','name'
17 | ) .rows (
18 | [ 'EUR','€','Euro' ],
19 | [ 'USD','$','US Dollar' ],
20 | [ 'GBP','£','British Pound' ],
21 | [ 'ILS','₪','Shekel' ],
22 | [ 'JPY','¥','Yen' ],
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/lib/schema/util/index.js:
--------------------------------------------------------------------------------
1 | const hasScalarFields = entity =>
2 | Object.entries(entity.elements).some(
3 | ([, el]) => !(shouldElementBeIgnored(el) || el.isAssociation || el.isComposition)
4 | )
5 |
6 | const _isFlatForeignKey = element => Boolean(element['@odata.foreignKey4'] || element._foreignKey4)
7 |
8 | const _isLocalized = element => element.name === 'localized' && element.target?.endsWith('.texts')
9 |
10 | // TODO: add check for @cds.api.ignore
11 | const shouldElementBeIgnored = element =>
12 | element.name.startsWith('up_') || _isLocalized(element) || _isFlatForeignKey(element)
13 |
14 | const isCompositionOfAspect = entity =>
15 | Object.values(entity.elements.up_?._target.elements ?? {}).some(e => e.targetAspect && e._target.name === entity.name)
16 |
17 | module.exports = { hasScalarFields, shouldElementBeIgnored, isCompositionOfAspect }
18 |
--------------------------------------------------------------------------------
/lib/resolvers/crud/create.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const { INSERT } = cds.ql
3 | const { ARGS } = require('../../constants')
4 | const { getArgumentByName, astToEntries } = require('../parse/ast2cqn')
5 | const { entriesStructureToEntityStructure } = require('./utils')
6 | const GraphQLRequest = require('../GraphQLRequest')
7 | const formatResult = require('../parse/ast/result')
8 |
9 | module.exports = async ({ req, res }, service, entity, selection) => {
10 | let query = INSERT.into(entity)
11 |
12 | const input = getArgumentByName(selection.arguments, ARGS.input)
13 | const entries = entriesStructureToEntityStructure(service, entity, astToEntries(input))
14 | query.entries(entries)
15 |
16 | const result = await service.dispatch(new GraphQLRequest({ req, res, query }))
17 |
18 | return formatResult(entity, selection, result, false)
19 | }
20 |
--------------------------------------------------------------------------------
/lib/compile.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const { generateSchema4 } = require('./schema')
3 | const { lexicographicSortSchema, printSchema } = require('graphql')
4 |
5 | function cds_compile_to_gql(csn, options = {}) {
6 | const model = cds.linked(csn)
7 | const serviceinfo = cds.compile.to.serviceinfo(csn, options)
8 | const services = Object.fromEntries(
9 | model.services
10 | .map(s => [s.name, new cds.ApplicationService(s.name, model)])
11 | // Only compile services with GraphQL endpoints
12 | .filter(([_, service]) =>
13 | serviceinfo.find(s => s.name === service.name)?.endpoints.some(e => e.kind === 'graphql')
14 | )
15 | )
16 |
17 | let schema = generateSchema4(services)
18 |
19 | if (options.sort) schema = lexicographicSortSchema(schema)
20 | if (/^obj|object$/i.test(options.as)) return schema
21 |
22 | return printSchema(schema)
23 | }
24 |
25 | module.exports = cds_compile_to_gql
26 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: CI
5 |
6 | on:
7 | push:
8 | branches: [ main ]
9 | pull_request:
10 | branches: [ main ]
11 |
12 | permissions:
13 | contents: read
14 |
15 | jobs:
16 |
17 | test:
18 | runs-on: ubuntu-latest
19 | strategy:
20 | matrix:
21 | node-version: [20.x, 22.x] # see https://nodejs.org/en/about/releases/
22 | steps:
23 | - uses: actions/checkout@v3
24 | - name: Use Node.js ${{ matrix.node-version }}
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: ${{ matrix.node-version }}
28 | # cache: 'npm' # only if package-lock is present
29 | - run: npm i
30 | - run: npm test
31 |
--------------------------------------------------------------------------------
/lib/resolvers/crud/utils/index.js:
--------------------------------------------------------------------------------
1 | const _objectStructureToEntityStructure = (service, entity, entry) => {
2 | for (const [k, v] of Object.entries(entry)) {
3 | const element = entity.elements[k]
4 | if (element.isComposition || element.isAssociation) {
5 | if (Array.isArray(v) && element.is2one) {
6 | entry[k] = v[0]
7 | } else if (!Array.isArray(v) && element.is2many) {
8 | entry[k] = [v]
9 | }
10 | entriesStructureToEntityStructure(service, element._target, v)
11 | }
12 | }
13 | return entry
14 | }
15 |
16 | const entriesStructureToEntityStructure = (service, entity, entries) => {
17 | if (Array.isArray(entries)) {
18 | for (const entry of entries) {
19 | _objectStructureToEntityStructure(service, entity, entry)
20 | }
21 | } else {
22 | _objectStructureToEntityStructure(service, entity, entries)
23 | }
24 | return entries
25 | }
26 |
27 | module.exports = { entriesStructureToEntityStructure }
28 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast2cqn/entries.js:
--------------------------------------------------------------------------------
1 | const { Kind } = require('graphql')
2 |
3 | const _parseObjectField = objectField => {
4 | const value = objectField.value
5 | switch (value.kind) {
6 | case Kind.LIST:
7 | return _parseListValue(value)
8 | case Kind.OBJECT:
9 | return _parseObjectValue(value)
10 | }
11 | return value.value
12 | }
13 |
14 | const _parseObjectValue = objectValue =>
15 | objectValue.fields.reduce((entry, objectField) => {
16 | entry[objectField.name.value] = _parseObjectField(objectField)
17 | return entry
18 | }, {})
19 |
20 | const _parseListValue = listValue => listValue.values.map(value => _parseObjectValue(value))
21 |
22 | const astToEntries = inputArg => {
23 | const value = inputArg.value
24 | switch (value.kind) {
25 | case Kind.LIST:
26 | return _parseListValue(value)
27 | case Kind.OBJECT:
28 | return _parseObjectValue(value)
29 | }
30 | }
31 |
32 | module.exports = astToEntries
33 |
--------------------------------------------------------------------------------
/lib/errorFormatter.js:
--------------------------------------------------------------------------------
1 | const { IS_PRODUCTION } = require('./utils')
2 |
3 | const ALLOWED_PROPERTIES_IN_PRODUCTION = ['code', 'message', 'target', 'details']
4 |
5 | const _sanitizeProperty = (key, value, out) => {
6 | if (IS_PRODUCTION) {
7 | if (ALLOWED_PROPERTIES_IN_PRODUCTION.includes(key)) return (out[key] = value)
8 | if (key.startsWith('$')) return (out[key] = value)
9 | return
10 | }
11 |
12 | if (key === 'stack') return (out['stacktrace'] = value.split('\n'))
13 |
14 | return (out[key] = value)
15 | }
16 |
17 | const errorFormatterFn = err => {
18 | const error = {}
19 |
20 | let properties = Object.keys(err).concat('message', 'stack')
21 |
22 | // No stack for outer error of multiple errors, since the stack is not meaningful
23 | if (err.details) properties = properties.filter(k => k !== 'stack')
24 |
25 | properties.forEach(k => _sanitizeProperty(k, err[k], error))
26 |
27 | return error
28 | }
29 |
30 | module.exports = errorFormatterFn
31 |
--------------------------------------------------------------------------------
/test/scripts/generate-schemas.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const cds = require('@sap/cds')
3 | // Load @cap-js/graphql plugin to ensure .to.gql and .to.graphql compile targets are registered
4 | require('../../cds-plugin')
5 | const path = require('path')
6 | const fs = require('fs')
7 | const { SCHEMAS_DIR } = require('../util')
8 | const { models } = require('../resources')
9 |
10 | ;(async () => {
11 | fs.rmSync(SCHEMAS_DIR, { recursive: true, force: true })
12 | fs.mkdirSync(SCHEMAS_DIR)
13 | for (const model of models) {
14 | console.log(`Generating GraphQL schema "${model.name}.gql"`)
15 | const csn = await cds.load(model.files, { docs: true })
16 | const graphQLSchema = cds.compile(csn).to.gql({ sort: true })
17 | const schemaPath = path.join(SCHEMAS_DIR, `${model.name}.gql`)
18 | const schemaPathDir = path.parse(schemaPath).dir
19 | if (!fs.existsSync(schemaPathDir)) fs.mkdirSync(schemaPathDir)
20 | fs.writeFileSync(schemaPath, graphQLSchema)
21 | }
22 | })()
23 |
--------------------------------------------------------------------------------
/test/resources/bookshop/db/schema.cds:
--------------------------------------------------------------------------------
1 | using { Currency, managed, sap } from '@sap/cds/common';
2 | namespace sap.capire.bookshop;
3 |
4 | entity Books : managed {
5 | key ID : Integer;
6 | title : localized String(111);
7 | descr : localized String(1111);
8 | author : Association to Authors;
9 | genre : Association to Genres;
10 | stock : Integer;
11 | price : Decimal;
12 | currency : Currency;
13 | image : LargeBinary @Core.MediaType : 'image/png';
14 | }
15 |
16 | entity Authors : managed {
17 | key ID : Integer;
18 | name : String(111);
19 | dateOfBirth : Date;
20 | dateOfDeath : Date;
21 | placeOfBirth : String;
22 | placeOfDeath : String;
23 | books : Association to many Books on books.author = $self;
24 | }
25 |
26 | /** Hierarchically organized Code List for Genres */
27 | entity Genres : sap.common.CodeList {
28 | key ID : Integer;
29 | parent : Association to Genres;
30 | children : Composition of many Genres on children.parent = $self;
31 | }
32 |
--------------------------------------------------------------------------------
/test/resources/concurrency/srv/concurrency.js:
--------------------------------------------------------------------------------
1 |
2 | const cds = require('@sap/cds')
3 | const sleep = require('node:timers/promises').setTimeout
4 |
5 | class ConcurrencyService extends cds.ApplicationService { init(){
6 |
7 | const logSleep = async (duration, message) => {
8 | console.log('BEGIN ' + message)
9 | await sleep(duration)
10 | console.log('END ' + message)
11 | }
12 |
13 | const SLEEP_DURATION_A = 300
14 | const SLEEP_DURATION_B = 100
15 | const SLEEP_DURATION_C = 200
16 |
17 | this.on('READ', 'A', () => logSleep(SLEEP_DURATION_A, 'READ A'))
18 | this.on('READ', 'B', () => logSleep(SLEEP_DURATION_B, 'READ B'))
19 | this.on('READ', 'C', () => logSleep(SLEEP_DURATION_C, 'READ C'))
20 |
21 | this.on('CREATE', 'A', () => logSleep(SLEEP_DURATION_A, 'CREATE A'))
22 | this.on('CREATE', 'B', () => logSleep(SLEEP_DURATION_B, 'CREATE B'))
23 | this.on('CREATE', 'C', () => logSleep(SLEEP_DURATION_C, 'CREATE C'))
24 |
25 | return super.init()
26 | }}
27 |
28 | module.exports = { ConcurrencyService }
--------------------------------------------------------------------------------
/lib/resolvers/crud/delete.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const { DELETE } = cds.ql
3 | const { ARGS } = require('../../constants')
4 | const { getArgumentByName, astToWhere } = require('../parse/ast2cqn')
5 | const GraphQLRequest = require('../GraphQLRequest')
6 | const { isPlainObject } = require('../utils')
7 |
8 | module.exports = async ({ req, res }, service, entity, selection) => {
9 | let query = DELETE.from(entity)
10 |
11 | const filter = getArgumentByName(selection.arguments, ARGS.filter)
12 | if (filter) query.where(astToWhere(filter))
13 |
14 | let result
15 | try {
16 | result = await service.dispatch(new GraphQLRequest({ req, res, query }))
17 | } catch (e) {
18 | if (e.code === 404) result = 0
19 | else throw e
20 | }
21 |
22 | // The CDS delete query returns the number of deleted entries
23 | // However, custom handlers can return non-numeric results for delete
24 | if (isPlainObject(result)) return 1
25 | if (Array.isArray(result)) return result.length
26 |
27 | return result
28 | }
29 |
--------------------------------------------------------------------------------
/lib/schema/args/index.js:
--------------------------------------------------------------------------------
1 | const { GraphQLInt } = require('graphql')
2 | const { ARGS } = require('../../constants')
3 | const filterGenerator = require('./filter')
4 | const orderByGenerator = require('./orderBy')
5 |
6 | module.exports = cache => {
7 | const generateArgumentsForType = cdsType => {
8 | const args = {
9 | [ARGS.top]: { type: GraphQLInt },
10 | [ARGS.skip]: { type: GraphQLInt }
11 | }
12 | const filter =
13 | cdsType.kind === 'entity'
14 | ? filterGenerator(cache).generateFilterForEntity(cdsType)
15 | : filterGenerator(cache).generateFilterForElement(cdsType, true)
16 | const orderBy =
17 | cdsType.kind === 'entity'
18 | ? orderByGenerator(cache).generateOrderByForEntity(cdsType)
19 | : orderByGenerator(cache).generateOrderByForElement(cdsType, true)
20 | if (filter) args[ARGS.filter] = { type: filter }
21 | if (orderBy) args[ARGS.orderBy] = { type: orderBy }
22 |
23 | return args
24 | }
25 |
26 | return { generateArgumentsForType }
27 | }
28 |
--------------------------------------------------------------------------------
/lib/schema/index.js:
--------------------------------------------------------------------------------
1 | const queryGenerator = require('./query')
2 | const mutationGenerator = require('./mutation')
3 | const { GraphQLSchema, validateSchema } = require('graphql')
4 | const { createRootResolvers, registerAliasFieldResolvers } = require('../resolvers')
5 |
6 | function generateSchema4(services) {
7 | const resolvers = createRootResolvers(services)
8 | const cache = new Map()
9 |
10 | const query = queryGenerator(cache).generateQueryObjectType(services, resolvers.Query)
11 | const mutation = mutationGenerator(cache).generateMutationObjectType(services, resolvers.Mutation)
12 | const schema = new GraphQLSchema({ query, mutation })
13 |
14 | registerAliasFieldResolvers(schema)
15 |
16 | const schemaValidationErrors = validateSchema(schema)
17 | if (schemaValidationErrors.length) {
18 | schemaValidationErrors.forEach(error => (error.severity = 'Error')) // Needed for cds-dk to decide logging based on log level
19 | throw new AggregateError(schemaValidationErrors, 'GraphQL schema validation failed')
20 | }
21 |
22 | return schema
23 | }
24 |
25 | module.exports = { generateSchema4 }
26 |
--------------------------------------------------------------------------------
/test/tests/invalid-schema.test.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | // Load @cap-js/graphql plugin to ensure .to.gql and .to.graphql compile targets are registered
3 | require('../../cds-plugin')
4 |
5 | const RES = path.join(__dirname, '../resources')
6 |
7 | describe('graphql - schema generation fails due to incompatible modelling', () => {
8 | describe('special characters', () => {
9 | it('in service name', async () => {
10 | const csn = await cds.load(RES + '/special-chars/srv/service')
11 | expect(() => {
12 | cds.compile(csn).to.graphql()
13 | }).toThrow(/SpecialCharsÄÖÜService/)
14 | })
15 |
16 | it('in entity name', async () => {
17 | const csn = await cds.load(RES + '/special-chars/srv/entity')
18 | expect(() => {
19 | cds.compile(csn).to.graphql()
20 | }).toThrow(/RootÄÖÜEntity/)
21 | })
22 |
23 | it('in element name', async () => {
24 | const csn = await cds.load(RES + '/special-chars/srv/element')
25 | expect(() => {
26 | cds.compile(csn).to.graphql()
27 | }).toThrow(/myÄÖÜElement/)
28 | })
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/lib/GraphQLAdapter.js:
--------------------------------------------------------------------------------
1 | const express = require('express')
2 | const { generateSchema4 } = require('./schema')
3 | const path = require('path')
4 | const cds = require('@sap/cds')
5 | const queryLogger = require('./logger')
6 | const { createHandler } = require('graphql-http/lib/use/express')
7 | const { formatError } = require('./resolvers/error')
8 |
9 | function GraphQLAdapter(options) {
10 | const defaults = { graphiql: true }
11 | options = { ...defaults, ...options }
12 |
13 | const errorFormatter = options.errorFormatter
14 | ? require(path.join(cds.root, options.errorFormatter))
15 | : require('./errorFormatter') // default error formatter
16 |
17 | const schema = generateSchema4(options.services)
18 |
19 | const queryHandler = (req, res) =>
20 | createHandler({ schema, context: { req, res, errorFormatter }, formatError, ...options })(req, res)
21 |
22 | const router = express.Router()
23 |
24 | router.use(queryLogger)
25 | if (options.graphiql) router.use(require('../app/graphiql'))
26 | router.use(queryHandler)
27 |
28 | return router
29 | }
30 |
31 | module.exports = GraphQLAdapter
32 |
--------------------------------------------------------------------------------
/test/resources/bookshop/srv/cat-service.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 |
3 | class CatalogService extends cds.ApplicationService { init(){
4 |
5 | const { Books } = cds.entities ('sap.capire.bookshop')
6 |
7 | // Reduce stock of ordered books if available stock suffices
8 | this.on ('submitOrder', async req => {
9 | const {book,quantity} = req.data
10 | if (quantity < 1) return req.reject (400,`quantity has to be 1 or more`)
11 | let b = await SELECT `stock` .from (Books,book)
12 | if (!b) return req.error (404,`Book #${book} doesn't exist`)
13 | let {stock} = b
14 | if (quantity > stock) return req.reject (409,`${quantity} exceeds stock for book #${book}`)
15 | await UPDATE (Books,book) .with ({ stock: stock -= quantity })
16 | await this.emit ('OrderedBook', { book, quantity, buyer:req.user.id })
17 | return { stock }
18 | })
19 |
20 | // Add some discount for overstocked books
21 | this.after ('READ','ListOfBooks', each => {
22 | if (each.stock > 111) each.title += ` -- 11% discount!`
23 | })
24 |
25 | return super.init()
26 | }}
27 |
28 | module.exports = { CatalogService }
29 |
--------------------------------------------------------------------------------
/test/tests/http.test.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const path = require('path')
3 | const util = require('util')
4 | const { axios } = cds
5 | .test('serve', 'srv/empty-service.cds')
6 | .in(path.join(__dirname, '../resources/empty-csn-definitions'))
7 | const _format = e => util.formatWithOptions({ colors: false, depth: null }, ...(Array.isArray(e) ? e : [e]))
8 |
9 | let _error = []
10 |
11 | describe('GraphQL express json parser error scenario', () => {
12 | beforeEach(() => {
13 | console.warn = (...s) => _error.push(s) // eslint-disable-line no-console
14 | })
15 |
16 | afterEach(() => {
17 | _error = []
18 | })
19 | test('should trigger InvalidJSON for malformed JSON', async () => {
20 | expect.hasAssertions()
21 | try {
22 | response = await axios.request({
23 | method: 'POST',
24 | url: `/graphql`,
25 | data: '{ some_value'
26 | })
27 | } catch (err) {
28 | expect(err.status).toBe(400)
29 | expect(err.response.data.error.message).toMatch(/not valid JSON/)
30 | expect(_format(_error[0])).toContain('InvalidJSON')
31 | }
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLDate.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getGraphQLValueError, parseDate } = require('./util')
3 |
4 | const ERROR_NON_STRING_VALUE = 'Date cannot represent non string value'
5 | const ERROR_NON_DATE_VALUE = 'Date values must be strings in the ISO 8601 format YYYY-MM-DD'
6 |
7 | const _parseDate = inputValueOrValueNode => {
8 | const date = parseDate(inputValueOrValueNode, ERROR_NON_DATE_VALUE)
9 | // Only return YYYY-MM-DD
10 | return date.slice(0, 10)
11 | }
12 |
13 | const parseValue = inputValue => {
14 | if (typeof inputValue !== 'string') throw getGraphQLValueError(ERROR_NON_STRING_VALUE, inputValue)
15 |
16 | return _parseDate(inputValue)
17 | }
18 |
19 | const parseLiteral = valueNode => {
20 | if (valueNode.kind !== Kind.STRING) throw getGraphQLValueError(ERROR_NON_STRING_VALUE, valueNode)
21 |
22 | return _parseDate(valueNode)
23 | }
24 |
25 | module.exports = new GraphQLScalarType({
26 | name: 'Date',
27 | description: 'The `Date` scalar type represents date values as strings in the ISO 8601 format `YYYY-MM-DD`.',
28 | parseValue,
29 | parseLiteral
30 | })
31 |
--------------------------------------------------------------------------------
/lib/schema/types/scalar.js:
--------------------------------------------------------------------------------
1 | const { GraphQLString, GraphQLBoolean, GraphQLFloat, GraphQLInt, GraphQLID } = require('graphql')
2 | const {
3 | GraphQLBinary,
4 | GraphQLDate,
5 | GraphQLDateTime,
6 | GraphQLDecimal,
7 | GraphQLInt16,
8 | GraphQLInt64,
9 | GraphQLTime,
10 | GraphQLTimestamp,
11 | GraphQLUInt8
12 | } = require('./custom')
13 |
14 | const CDS_TO_GRAPHQL_TYPES = {
15 | 'cds.Binary': GraphQLBinary,
16 | 'cds.Boolean': GraphQLBoolean,
17 | 'cds.Date': GraphQLDate,
18 | 'cds.DateTime': GraphQLDateTime,
19 | 'cds.Decimal': GraphQLDecimal,
20 | 'cds.DecimalFloat': GraphQLFloat,
21 | 'cds.Double': GraphQLFloat,
22 | 'cds.Int16': GraphQLInt16,
23 | 'cds.Int32': GraphQLInt,
24 | 'cds.Int64': GraphQLInt64,
25 | 'cds.Integer': GraphQLInt,
26 | 'cds.Integer64': GraphQLInt64,
27 | 'cds.LargeBinary': GraphQLBinary,
28 | 'cds.LargeString': GraphQLString,
29 | 'cds.String': GraphQLString,
30 | 'cds.Time': GraphQLTime,
31 | 'cds.Timestamp': GraphQLTimestamp,
32 | 'cds.UInt8': GraphQLUInt8,
33 | 'cds.UUID': GraphQLID
34 | }
35 |
36 | module.exports = { cdsToGraphQLScalarType: element => CDS_TO_GRAPHQL_TYPES[element._type] }
37 |
--------------------------------------------------------------------------------
/test/tests/schema.test.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | // Load @cap-js/graphql plugin to ensure .to.gql and .to.graphql compile targets are registered
4 | require('../../cds-plugin')
5 |
6 | const { models } = require('../resources')
7 | const { SCHEMAS_DIR } = require('../util')
8 | const { printSchema, validateSchema } = require('graphql')
9 |
10 | describe('graphql - schema generation', () => {
11 | describe('generated schema should match saved schema', () => {
12 | models.forEach(model => {
13 | it('should process model ' + model.name, async () => {
14 | const csn = await cds.load(model.files, { docs: true })
15 | const generatedSchemaObject = cds.compile(csn).to.graphql({ as: 'obj', sort: true })
16 | const schemaValidationErrors = validateSchema(generatedSchemaObject)
17 | expect(schemaValidationErrors.length).toEqual(0)
18 |
19 | const loadedSchema = fs.readFileSync(path.join(SCHEMAS_DIR, `${model.name}.gql`), 'utf-8')
20 | const generatedSchema = printSchema(generatedSchemaObject)
21 | expect(loadedSchema).toEqual(generatedSchema)
22 | })
23 | })
24 | })
25 | })
26 |
--------------------------------------------------------------------------------
/test/resources/bookshop/test/genres.http:
--------------------------------------------------------------------------------
1 | #################################################
2 | #
3 | # Genres
4 | #
5 |
6 | GET http://localhost:4004/test/Genres?
7 | ###
8 |
9 | GET http://localhost:4004/test/Genres?
10 | &$filter=parent_ID eq null&$select=name
11 | &$expand=children($select=name)
12 | ###
13 |
14 | POST http://localhost:4004/test/Genres?
15 | Content-Type: application/json
16 |
17 | { "ID":100, "name":"Some Sample Genres...", "children":[
18 | { "ID":101, "name":"Cat", "children":[
19 | { "ID":102, "name":"Kitty", "children":[
20 | { "ID":103, "name":"Kitty Cat", "children":[
21 | { "ID":104, "name":"Aristocat" } ]},
22 | { "ID":105, "name":"Kitty Bat" } ]},
23 | { "ID":106, "name":"Catwoman", "children":[
24 | { "ID":107, "name":"Catalina" } ]} ]},
25 | { "ID":108, "name":"Catweazle" }
26 | ]}
27 | ###
28 |
29 | GET http://localhost:4004/test/Genres(100)?
30 | # &$expand=children
31 | # &$expand=children($expand=children($expand=children($expand=children)))
32 | ###
33 |
34 | DELETE http://localhost:4004/test/Genres(103)
35 | ###
36 |
37 | DELETE http://localhost:4004/test/Genres(100)
38 | ###
39 |
--------------------------------------------------------------------------------
/lib/constants.js:
--------------------------------------------------------------------------------
1 | const CONNECTION_FIELDS = {
2 | nodes: 'nodes',
3 | totalCount: 'totalCount'
4 | }
5 |
6 | const ARGS = {
7 | input: 'input',
8 | filter: 'filter',
9 | orderBy: 'orderBy',
10 | top: 'top',
11 | skip: 'skip'
12 | }
13 |
14 | const RELATIONAL_OPERATORS = {
15 | eq: 'eq',
16 | ne: 'ne',
17 | gt: 'gt',
18 | ge: 'ge',
19 | le: 'le',
20 | lt: 'lt'
21 | }
22 |
23 | const LOGICAL_OPERATORS = {
24 | in: 'in'
25 | }
26 |
27 | const STRING_OPERATIONS = {
28 | startswith: 'startswith',
29 | endswith: 'endswith',
30 | contains: 'contains'
31 | }
32 |
33 | const OPERATOR_LIST_SUPPORT = {
34 | [RELATIONAL_OPERATORS.eq]: false,
35 | [RELATIONAL_OPERATORS.ne]: true,
36 | [RELATIONAL_OPERATORS.gt]: false,
37 | [RELATIONAL_OPERATORS.ge]: false,
38 | [RELATIONAL_OPERATORS.le]: false,
39 | [RELATIONAL_OPERATORS.lt]: false,
40 | [LOGICAL_OPERATORS.in]: true,
41 | [STRING_OPERATIONS.startswith]: false,
42 | [STRING_OPERATIONS.endswith]: false,
43 | [STRING_OPERATIONS.contains]: true
44 | }
45 |
46 | module.exports = {
47 | CONNECTION_FIELDS,
48 | ARGS,
49 | RELATIONAL_OPERATORS,
50 | LOGICAL_OPERATORS,
51 | STRING_OPERATIONS,
52 | OPERATOR_LIST_SUPPORT
53 | }
54 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLDateTime.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getGraphQLValueError, parseDate } = require('./util')
3 |
4 | const ERROR_NON_STRING_VALUE = 'DateTime cannot represent non string value'
5 | const ERROR_NON_DATE_TIME_VALUE = 'DateTime values must be strings in the ISO 8601 format YYYY-MM-DDThh-mm-ssTZD'
6 |
7 | const _parseDate = inputValueOrValueNode => {
8 | const date = parseDate(inputValueOrValueNode, ERROR_NON_DATE_TIME_VALUE)
9 | // Cut off milliseconds
10 | return date.slice(0, 19) + 'Z'
11 | }
12 |
13 | const parseValue = inputValue => {
14 | if (typeof inputValue !== 'string') throw getGraphQLValueError(ERROR_NON_STRING_VALUE, inputValue)
15 |
16 | return _parseDate(inputValue)
17 | }
18 |
19 | const parseLiteral = valueNode => {
20 | if (valueNode.kind !== Kind.STRING) throw getGraphQLValueError(ERROR_NON_STRING_VALUE, valueNode)
21 |
22 | return _parseDate(valueNode)
23 | }
24 |
25 | module.exports = new GraphQLScalarType({
26 | name: 'DateTime',
27 | description:
28 | 'The `DateTime` scalar type represents datetime values as strings in the ISO 8601 format `YYYY-MM-DDThh-mm-ssTZD`.',
29 | parseValue,
30 | parseLiteral
31 | })
32 |
--------------------------------------------------------------------------------
/test/resources/models.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "bookshop-graphql",
4 | "files": [
5 | "./bookshop-graphql/srv/admin-service.cds",
6 | "./bookshop-graphql/srv/cat-service.cds"
7 | ]
8 | },
9 | {
10 | "name": "annotations",
11 | "files": ["./annotations/srv/protocols.cds"]
12 | },
13 | {
14 | "name": "types",
15 | "files": ["./types/srv/types.cds"]
16 | },
17 | {
18 | "name": "model-structure/composition-of-aspect",
19 | "files": ["./model-structure/srv/composition-of-aspect.cds"]
20 | },
21 | {
22 | "name": "edge-cases/field-named-localized",
23 | "files": ["./edge-cases/srv/field-named-localized.cds"]
24 | },
25 | {
26 | "name": "edge-cases/fields-with-connection-names",
27 | "files": ["./edge-cases/srv/fields-with-connection-names.cds"]
28 | },
29 | {
30 | "name": "empty-csn-definitions/service",
31 | "files": ["./empty-csn-definitions/srv/empty-service.cds"]
32 | },
33 | {
34 | "name": "empty-csn-definitions/entity",
35 | "files": ["./empty-csn-definitions/srv/empty-entity.cds"]
36 | },
37 | {
38 | "name": "empty-csn-definitions/aspect",
39 | "files": ["./empty-csn-definitions/srv/empty-aspect.cds"]
40 | }
41 | ]
42 |
--------------------------------------------------------------------------------
/test/resources/annotations/srv/protocols.cds:
--------------------------------------------------------------------------------
1 | context protocols {
2 | entity A {
3 | key id : UUID;
4 | }
5 | }
6 |
7 | service NotAnnotated {
8 | entity A as projection on protocols.A;
9 | }
10 |
11 | @protocol: 'none'
12 | service AnnotatedWithAtProtocolNone {
13 | entity A as projection on protocols.A;
14 | }
15 |
16 | @protocol: 'odata'
17 | service AnnotatedWithNonGraphQL {
18 | entity A as projection on protocols.A;
19 | }
20 |
21 | @graphql
22 | service AnnotatedWithAtGraphQL {
23 | entity A as projection on protocols.A;
24 | }
25 |
26 | @protocol: 'graphql'
27 | service AnnotatedWithAtProtocolString {
28 | entity A as projection on protocols.A;
29 | }
30 |
31 | @protocol: ['graphql']
32 | service AnnotatedWithAtProtocolStringList {
33 | entity A as projection on protocols.A;
34 | }
35 |
36 | @protocol: [{kind: 'graphql'}]
37 | service AnnotatedWithAtProtocolObjectList {
38 | entity A as projection on protocols.A;
39 | }
40 |
41 | @protocol: { graphql }
42 | service AnnotatedWithAtProtocolObjectWithKey {
43 | entity A as projection on protocols.A;
44 | }
45 |
46 | @protocol: { graphql: 'dummy' }
47 | service AnnotatedWithAtProtocolObjectWithKeyAndValue {
48 | entity A as projection on protocols.A;
49 | }
50 |
--------------------------------------------------------------------------------
/test/schemas/empty-csn-definitions/aspect.gql:
--------------------------------------------------------------------------------
1 | type EmptyAspectService {
2 | Root(filter: [EmptyAspectService_Root_filter], orderBy: [EmptyAspectService_Root_orderBy], skip: Int, top: Int): EmptyAspectService_Root_connection
3 | }
4 |
5 | type EmptyAspectService_Root {
6 | ID: ID
7 | }
8 |
9 | input EmptyAspectService_Root_C {
10 | ID: ID
11 | }
12 |
13 | type EmptyAspectService_Root_connection {
14 | nodes: [EmptyAspectService_Root]
15 | totalCount: Int
16 | }
17 |
18 | input EmptyAspectService_Root_filter {
19 | ID: [ID_filter]
20 | }
21 |
22 | type EmptyAspectService_Root_input {
23 | create(input: [EmptyAspectService_Root_C]!): [EmptyAspectService_Root]
24 | delete(filter: [EmptyAspectService_Root_filter]!): Int
25 | }
26 |
27 | input EmptyAspectService_Root_orderBy {
28 | ID: SortDirection
29 | }
30 |
31 | type EmptyAspectService_input {
32 | Root: EmptyAspectService_Root_input
33 | }
34 |
35 | input ID_filter {
36 | eq: ID
37 | ge: ID
38 | gt: ID
39 | in: [ID]
40 | le: ID
41 | lt: ID
42 | ne: [ID]
43 | }
44 |
45 | type Mutation {
46 | EmptyAspectService: EmptyAspectService_input
47 | }
48 |
49 | type Query {
50 | EmptyAspectService: EmptyAspectService
51 | }
52 |
53 | enum SortDirection {
54 | asc
55 | desc
56 | }
--------------------------------------------------------------------------------
/test/resources/concurrency/srv/data-and-errors.js:
--------------------------------------------------------------------------------
1 |
2 | const cds = require('@sap/cds')
3 | const sleep = require('node:timers/promises').setTimeout
4 |
5 | class DataAndErrorsService extends cds.ApplicationService { init(){
6 |
7 | const SLEEP_DURATION_A = 300
8 | const SLEEP_DURATION_B = 100
9 | const SLEEP_DURATION_C = 200
10 |
11 | this.on('READ', 'A', async () => {
12 | await sleep(SLEEP_DURATION_A)
13 | return [{ timestamp: new Date().toISOString() }]
14 | })
15 | this.on('READ', 'B', async () => {
16 | await sleep(SLEEP_DURATION_B)
17 | throw 'My error on READ B'
18 | })
19 | this.on('READ', 'C', async () => {
20 | await sleep(SLEEP_DURATION_C)
21 | return [{ timestamp: new Date().toISOString() }]
22 | })
23 |
24 | this.on('CREATE', 'A', async () => {
25 | await sleep(SLEEP_DURATION_A)
26 | return [{ timestamp: new Date().toISOString() }]
27 | })
28 | this.on('CREATE', 'B', async () => {
29 | await sleep(SLEEP_DURATION_B)
30 | throw new Error('My error on CREATE B')
31 | })
32 | this.on('CREATE', 'C', async () => {
33 | await sleep(SLEEP_DURATION_C)
34 | return [{ timestamp: new Date().toISOString() }]
35 | })
36 |
37 | return super.init()
38 | }}
39 |
40 | module.exports = { DataAndErrorsService }
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLTime.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getGraphQLValueError, getValueFromInputValueOrValueNode, ISO_TIME_REGEX } = require('./util')
3 |
4 | const ERROR_NON_STRING_VALUE = 'Time cannot represent non string value'
5 | const ERROR_NON_TIME_VALUE = 'Time values must be strings in the ISO 8601 format hh:mm:ss'
6 |
7 | const _validateTime = inputValueOrValueNode => {
8 | const value = getValueFromInputValueOrValueNode(inputValueOrValueNode)
9 | if (!ISO_TIME_REGEX.test(value)) throw getGraphQLValueError(ERROR_NON_TIME_VALUE, inputValueOrValueNode)
10 | }
11 |
12 | const parseValue = inputValue => {
13 | if (typeof inputValue !== 'string') throw getGraphQLValueError(ERROR_NON_STRING_VALUE, inputValue)
14 |
15 | _validateTime(inputValue)
16 |
17 | return inputValue
18 | }
19 |
20 | const parseLiteral = valueNode => {
21 | if (valueNode.kind !== Kind.STRING) throw getGraphQLValueError(ERROR_NON_STRING_VALUE, valueNode)
22 |
23 | _validateTime(valueNode)
24 |
25 | return valueNode.value
26 | }
27 |
28 | module.exports = new GraphQLScalarType({
29 | name: 'Time',
30 | description: 'The `Time` scalar type represents time values as strings in the ISO 8601 format `hh:mm:ss`.',
31 | parseValue,
32 | parseLiteral
33 | })
34 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLInt16.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getGraphQLValueError, validateRange } = require('./util')
3 |
4 | const ERROR_NON_INTEGER_VALUE = 'Int16 cannot represent non integer value'
5 | const ERROR_NON_16_BIT_INTEGER_VALUE = 'Int16 must be an integer value between -(2^15) and 2^15 - 1'
6 |
7 | const MAX_INT16 = 32767 // 2^15 - 1
8 | const MIN_INT16 = -32768 // (-2^15)
9 |
10 | const parseValue = inputValue => {
11 | if (typeof inputValue !== 'number') throw getGraphQLValueError(ERROR_NON_INTEGER_VALUE, inputValue)
12 |
13 | validateRange(inputValue, MIN_INT16, MAX_INT16, ERROR_NON_16_BIT_INTEGER_VALUE)
14 |
15 | return inputValue
16 | }
17 |
18 | const parseLiteral = valueNode => {
19 | if (valueNode.kind !== Kind.INT) throw getGraphQLValueError(ERROR_NON_INTEGER_VALUE, valueNode)
20 |
21 | const num = parseInt(valueNode.value, 10)
22 | validateRange(num, MIN_INT16, MAX_INT16, ERROR_NON_16_BIT_INTEGER_VALUE, valueNode)
23 |
24 | return num
25 | }
26 |
27 | module.exports = new GraphQLScalarType({
28 | name: 'Int16',
29 | description:
30 | 'The `Int16` scalar type represents 16-bit non-fractional signed whole numeric values. Int16 can represent values between -(2^15) and 2^15 - 1.',
31 | parseValue,
32 | parseLiteral
33 | })
34 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLUInt8.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getGraphQLValueError, validateRange } = require('./util')
3 |
4 | const ERROR_NON_INTEGER_VALUE = 'UInt8 cannot represent non integer value'
5 | const ERROR_NON_8_BIT_UNSIGNED_INTEGER_VALUE = 'UInt8 must be an integer value between 0 and 2^8 - 1'
6 |
7 | const MAX_UINT8 = 255 // 2^8 - 1
8 | const MIN_UINT8 = 0 // 0
9 |
10 | const parseValue = inputValue => {
11 | if (typeof inputValue !== 'number') throw getGraphQLValueError(ERROR_NON_INTEGER_VALUE, inputValue)
12 |
13 | validateRange(inputValue, MIN_UINT8, MAX_UINT8, ERROR_NON_8_BIT_UNSIGNED_INTEGER_VALUE)
14 |
15 | return inputValue
16 | }
17 |
18 | const parseLiteral = valueNode => {
19 | if (valueNode.kind !== Kind.INT) throw getGraphQLValueError(ERROR_NON_INTEGER_VALUE, valueNode)
20 |
21 | const num = parseInt(valueNode.value, 10)
22 | validateRange(num, MIN_UINT8, MAX_UINT8, ERROR_NON_8_BIT_UNSIGNED_INTEGER_VALUE, valueNode)
23 |
24 | return num
25 | }
26 |
27 | module.exports = new GraphQLScalarType({
28 | name: 'UInt8',
29 | description:
30 | 'The `UInt8` scalar type represents 8-bit non-fractional unsigned whole numeric values. UInt8 can represent values between 0 and 2^8 - 1.',
31 | parseValue,
32 | parseLiteral
33 | })
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cap-js/graphql",
3 | "version": "0.15.0",
4 | "description": "CDS protocol adapter for GraphQL",
5 | "keywords": [
6 | "CAP",
7 | "CDS",
8 | "GraphQL"
9 | ],
10 | "author": "SAP SE (https://www.sap.com)",
11 | "license": "Apache-2.0",
12 | "repository": "cap-js/graphql",
13 | "homepage": "https://cap.cloud.sap/",
14 | "main": "index.js",
15 | "files": [
16 | "app/",
17 | "lib/",
18 | "index.js",
19 | "cds-plugin.js",
20 | "README.md",
21 | "CHANGELOG.md",
22 | "LICENSE"
23 | ],
24 | "engines": {
25 | "node": ">=20"
26 | },
27 | "scripts": {
28 | "prettier": "npm_config_yes=true npx prettier@latest --write app lib test",
29 | "prettier:check": "npm_config_yes=true npx prettier@latest --check app lib test",
30 | "lint": "npm_config_yes=true npx eslint@latest .",
31 | "test": "jest --silent",
32 | "test:generate-schemas": "node ./test/scripts/generate-schemas.js"
33 | },
34 | "dependencies": {
35 | "graphql": "^16",
36 | "graphql-http": "^1.18.0"
37 | },
38 | "peerDependencies": {
39 | "@sap/cds": ">=9"
40 | },
41 | "devDependencies": {
42 | "@cap-js/graphql": "file:.",
43 | "@cap-js/sqlite": ">=2",
44 | "@cap-js/cds-test": ">=0",
45 | "express": "^4.17.1",
46 | "jest": "^30"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: Release
5 |
6 | on:
7 | push:
8 | branches: [ release ]
9 |
10 | permissions:
11 | contents: write
12 | id-token: write
13 |
14 | jobs:
15 |
16 | publish-npm:
17 | runs-on: ubuntu-latest
18 | environment: npm
19 | steps:
20 | - uses: actions/checkout@v3
21 | - uses: actions/setup-node@v3
22 | with:
23 | node-version: 24
24 | registry-url: https://registry.npmjs.org/
25 | - run: npm i
26 | - run: npm test
27 | - name: get-version
28 | id: package-version
29 | uses: martinbeentjes/npm-get-version-action@v1.2.3
30 | - name: Parse changelog
31 | id: parse-changelog
32 | uses: schwma/parse-changelog-action@v1.0.0
33 | with:
34 | version: '${{ steps.package-version.outputs.current-version }}'
35 | - name: Create a GitHub release
36 | uses: ncipollo/release-action@v1
37 | with:
38 | tag: 'v${{ steps.package-version.outputs.current-version }}'
39 | body: '${{ steps.parse-changelog.outputs.body }}'
40 | - run: npm publish --access public --provenance
41 |
--------------------------------------------------------------------------------
/test/schemas/empty-csn-definitions/entity.gql:
--------------------------------------------------------------------------------
1 | type EmptyEntityService {
2 | NonEmptyEntity(filter: [EmptyEntityService_NonEmptyEntity_filter], orderBy: [EmptyEntityService_NonEmptyEntity_orderBy], skip: Int, top: Int): EmptyEntityService_NonEmptyEntity_connection
3 | }
4 |
5 | type EmptyEntityService_NonEmptyEntity {
6 | ID: ID
7 | }
8 |
9 | input EmptyEntityService_NonEmptyEntity_C {
10 | ID: ID
11 | }
12 |
13 | type EmptyEntityService_NonEmptyEntity_connection {
14 | nodes: [EmptyEntityService_NonEmptyEntity]
15 | totalCount: Int
16 | }
17 |
18 | input EmptyEntityService_NonEmptyEntity_filter {
19 | ID: [ID_filter]
20 | }
21 |
22 | type EmptyEntityService_NonEmptyEntity_input {
23 | create(input: [EmptyEntityService_NonEmptyEntity_C]!): [EmptyEntityService_NonEmptyEntity]
24 | delete(filter: [EmptyEntityService_NonEmptyEntity_filter]!): Int
25 | }
26 |
27 | input EmptyEntityService_NonEmptyEntity_orderBy {
28 | ID: SortDirection
29 | }
30 |
31 | type EmptyEntityService_input {
32 | NonEmptyEntity: EmptyEntityService_NonEmptyEntity_input
33 | }
34 |
35 | input ID_filter {
36 | eq: ID
37 | ge: ID
38 | gt: ID
39 | in: [ID]
40 | le: ID
41 | lt: ID
42 | ne: [ID]
43 | }
44 |
45 | type Mutation {
46 | EmptyEntityService: EmptyEntityService_input
47 | }
48 |
49 | type Query {
50 | EmptyEntityService: EmptyEntityService
51 | }
52 |
53 | enum SortDirection {
54 | asc
55 | desc
56 | }
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLTimestamp.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getGraphQLValueError, parseDate } = require('./util')
3 |
4 | const ERROR_NON_STRING_VALUE = 'Timestamp cannot represent non string value'
5 | const ERROR_NON_TIMESTAMP_VALUE =
6 | 'Timestamp values must be strings in the ISO 8601 format YYYY-MM-DDThh-mm-ss.sTZD with up to 7 digits of fractional seconds'
7 |
8 | const _validateTimestamp = inputValueOrValueNode => {
9 | // Only use for validation, not for parsing, to avoid cutting off milliseconds precision with more than 3 decimal places
10 | parseDate(inputValueOrValueNode, ERROR_NON_TIMESTAMP_VALUE)
11 | }
12 |
13 | const parseValue = inputValue => {
14 | if (typeof inputValue !== 'string') throw getGraphQLValueError(ERROR_NON_STRING_VALUE, inputValue)
15 |
16 | _validateTimestamp(inputValue)
17 |
18 | return inputValue
19 | }
20 |
21 | const parseLiteral = valueNode => {
22 | if (valueNode.kind !== Kind.STRING) throw getGraphQLValueError(ERROR_NON_STRING_VALUE, valueNode)
23 |
24 | _validateTimestamp(valueNode)
25 |
26 | return valueNode.value
27 | }
28 |
29 | module.exports = new GraphQLScalarType({
30 | name: 'Timestamp',
31 | description:
32 | 'The `Timestamp` scalar type represents timestamp values as strings in the ISO 8601 format `YYYY-MM-DDThh-mm-ss.sTZD` with up to 7 digits of fractional seconds.',
33 | parseValue,
34 | parseLiteral
35 | })
36 |
--------------------------------------------------------------------------------
/lib/resolvers/crud/read.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const { SELECT } = cds.ql
3 | const { ARGS, CONNECTION_FIELDS } = require('../../constants')
4 | const { getArgumentByName, astToColumns, astToWhere, astToOrderBy, astToLimit } = require('../parse/ast2cqn')
5 | const GraphQLRequest = require('../GraphQLRequest')
6 | const formatResult = require('../parse/ast/result')
7 |
8 | module.exports = async ({ req, res }, service, entity, selection) => {
9 | const selections = selection.selectionSet.selections
10 | const args = selection.arguments
11 |
12 | let query = SELECT.from(entity)
13 | const columns = astToColumns(entity, selection.selectionSet.selections, true)
14 | if (columns.length) query.columns(columns)
15 |
16 | const filter = getArgumentByName(args, ARGS.filter)
17 | if (filter) {
18 | const where = astToWhere(filter)
19 | if (where) query.where(where)
20 | }
21 |
22 | const orderBy = getArgumentByName(args, ARGS.orderBy)
23 | if (orderBy) query.orderBy(astToOrderBy(orderBy))
24 |
25 | const top = getArgumentByName(args, ARGS.top)
26 | const skip = getArgumentByName(args, ARGS.skip)
27 | if (top) query.limit(astToLimit(top, skip))
28 |
29 | if (selections.find(s => s.name.value === CONNECTION_FIELDS.totalCount)) query.SELECT.count = true
30 |
31 | const result = await service.dispatch(new GraphQLRequest({ req, res, query }))
32 |
33 | return formatResult(entity, selection, result, true)
34 | }
35 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLBinary.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getGraphQLValueError } = require('./util')
3 |
4 | const ERROR_NON_STRING_VALUE = 'Binary cannot represent non string value'
5 |
6 | const serialize = value => {
7 | // Normalize to base64url string
8 | const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value, 'base64')
9 | const base64url = buffer.toString('base64url')
10 | // Buffer base64url encoding does not have padding by default -> add it
11 | return base64url.padEnd(Math.ceil(base64url.length / 4) * 4, '=')
12 | }
13 |
14 | const parseValue = inputValue => {
15 | if (typeof inputValue !== 'string') throw getGraphQLValueError(ERROR_NON_STRING_VALUE, inputValue)
16 |
17 | return Buffer.from(inputValue, 'base64')
18 | }
19 |
20 | const parseLiteral = valueNode => {
21 | const { kind, value } = valueNode
22 |
23 | if (kind !== Kind.STRING) throw getGraphQLValueError(ERROR_NON_STRING_VALUE, valueNode)
24 |
25 | // WORKAROUND: value could have already been parsed to a Buffer, necessary because of manual parsing in enrich AST
26 | if (Buffer.isBuffer(value)) return value
27 |
28 | return Buffer.from(value, 'base64')
29 | }
30 |
31 | module.exports = new GraphQLScalarType({
32 | name: 'Binary',
33 | description: 'The `Binary` scalar type represents binary values as `base64url` encoded strings.',
34 | serialize,
35 | parseValue,
36 | parseLiteral
37 | })
38 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/util/index.js:
--------------------------------------------------------------------------------
1 | const { GraphQLError, print } = require('graphql')
2 |
3 | const ISO_TIME_NO_MILLIS = '(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d'
4 |
5 | const ISO_TIME_REGEX = new RegExp(`^${ISO_TIME_NO_MILLIS}$`, 'i')
6 |
7 | const _isValueNode = value => value.kind
8 |
9 | const getValueFromInputValueOrValueNode = value => (_isValueNode(value) ? value.value : value)
10 |
11 | const getGraphQLValueError = (message, inputValueOrValueNode) => {
12 | const valueNode = _isValueNode(inputValueOrValueNode) && inputValueOrValueNode
13 | const value = getValueFromInputValueOrValueNode(inputValueOrValueNode)
14 | const formattedValue = valueNode ? print(valueNode) : typeof value === 'string' ? '"' + value + '"' : value
15 | return new GraphQLError(`${message}: ${formattedValue}`, valueNode)
16 | }
17 |
18 | const validateRange = (num, min, max, errorMessage, valueNode) => {
19 | if (num > max || num < min) throw getGraphQLValueError(errorMessage, valueNode || num)
20 | }
21 |
22 | const parseDate = (inputValueOrValueNode, errorMessage) => {
23 | const value = getValueFromInputValueOrValueNode(inputValueOrValueNode)
24 | try {
25 | return new Date(value).toISOString()
26 | } catch (e) {
27 | throw getGraphQLValueError(errorMessage, inputValueOrValueNode)
28 | }
29 | }
30 |
31 | module.exports = {
32 | ISO_TIME_REGEX,
33 | getValueFromInputValueOrValueNode,
34 | getGraphQLValueError,
35 | validateRange,
36 | parseDate
37 | }
38 |
--------------------------------------------------------------------------------
/test/tests/graphiql.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - GraphiQL', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../util')
5 |
6 | const { axios, GET } = cds.test(path.join(__dirname, '../resources/bookshop-graphql'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 | axios.defaults.headers = {
10 | authorization: 'Basic YWxpY2U6'
11 | }
12 |
13 | test('GET request to endpoint should serve HTML containing GraphiQL', async () => {
14 | const response = await GET('/graphql')
15 | expect(response.headers['content-type']).toMatch(/text\/html/)
16 | expect(response.data).toMatch(/[\s\S]*graphiql[\s\S]*<\/html>/)
17 | })
18 |
19 | test('query via GET request with URL parameter', async () => {
20 | const query = gql`
21 | {
22 | AdminService {
23 | Books {
24 | nodes {
25 | title
26 | }
27 | }
28 | }
29 | }
30 | `
31 | const data = {
32 | AdminService: {
33 | Books: {
34 | nodes: [
35 | { title: 'Wuthering Heights' },
36 | { title: 'Jane Eyre' },
37 | { title: 'The Raven' },
38 | { title: 'Eleonora' },
39 | { title: 'Catweazle' }
40 | ]
41 | }
42 | }
43 | }
44 | const response = await GET('/graphql?query=' + query)
45 | expect(response.data).toEqual({ data })
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/app/graphiql.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Loading GraphiQL...
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/test/tests/custom-error-formatter.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - custom error formatter function', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../util')
5 |
6 | const { axios, POST } = cds.test(path.join(__dirname, '../resources/custom-error-formatter'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 |
10 | test('response contains error formatted by a custom error formatter function', async () => {
11 | const query = gql`
12 | {
13 | CustomErrorFormatterService {
14 | A {
15 | nodes {
16 | ID
17 | }
18 | }
19 | }
20 | }
21 | `
22 | const errors = [
23 | {
24 | message: expect.stringMatching(/oops! multiple/i),
25 | extensions: {
26 | custom: 'This property is added by the custom error formatter',
27 | count: 2,
28 | details: [
29 | {
30 | message: 'Oops! Some Custom Error Message 1',
31 | custom: 'This property is added by the custom error formatter',
32 | count: 0
33 | },
34 | {
35 | message: 'Oops! Some Custom Error Message 2',
36 | custom: 'This property is added by the custom error formatter',
37 | count: 1
38 | }
39 | ]
40 | }
41 | }
42 | ]
43 | const response = await POST('/graphql', { query })
44 | expect(response.data).toMatchObject({ errors })
45 | })
46 | })
47 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast2cqn/columns.js:
--------------------------------------------------------------------------------
1 | const { getPotentiallyNestedNodesSelections } = require('../util')
2 | const { getArgumentByName } = require('./utils')
3 | const astToWhere = require('./where')
4 | const astToOrderBy = require('./orderBy')
5 | const astToLimit = require('./limit')
6 | const { ARGS } = require('../../../constants')
7 |
8 | const astToColumns = (entity, selections, isConnection) => {
9 | let columns = []
10 |
11 | for (const selection of getPotentiallyNestedNodesSelections(selections, isConnection)) {
12 | const args = selection.arguments
13 |
14 | const fieldName = selection.name.value
15 | const element = entity.elements[fieldName]
16 | const column = { ref: [fieldName] }
17 | if (selection.alias) column.as = selection.alias.value
18 |
19 | if (selection.selectionSet?.selections) {
20 | const columns = astToColumns(element._target, selection.selectionSet.selections, element.is2many)
21 | // columns is empty if only __typename was selected (which was filtered out in the enriched AST)
22 | column.expand = columns.length > 0 ? columns : ['*']
23 | }
24 |
25 | const filter = getArgumentByName(args, ARGS.filter)
26 | if (filter) column.where = astToWhere(filter)
27 |
28 | const orderBy = getArgumentByName(args, ARGS.orderBy)
29 | if (orderBy) column.orderBy = astToOrderBy(orderBy)
30 |
31 | const top = getArgumentByName(args, ARGS.top)
32 | const skip = getArgumentByName(args, ARGS.skip)
33 | if (top) column.limit = astToLimit(top, skip)
34 |
35 | columns.push(column)
36 | }
37 |
38 | return columns
39 | }
40 |
41 | module.exports = astToColumns
42 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLDecimal.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getValueFromInputValueOrValueNode, getGraphQLValueError } = require('./util')
3 |
4 | const ERROR_VARIABLE_NON_STRING_VALUE = 'Decimal variable value must be represented by a string'
5 | const ERROR_NON_NUMERIC_VALUE = 'Decimal must be a numeric value'
6 |
7 | const _validateIsDecimal = inputValueOrValueNode => {
8 | const value = getValueFromInputValueOrValueNode(inputValueOrValueNode)
9 | // Ignore rounding, only used to check if is finite valid number
10 | let number = Number(value)
11 | if (value.trim().length === 0 || isNaN(number) || !isFinite(number))
12 | throw getGraphQLValueError(ERROR_NON_NUMERIC_VALUE, inputValueOrValueNode)
13 | }
14 |
15 | const serialize = value => String(value)
16 |
17 | const parseValue = inputValue => {
18 | if (typeof inputValue !== 'string') throw getGraphQLValueError(ERROR_VARIABLE_NON_STRING_VALUE, inputValue)
19 |
20 | _validateIsDecimal(inputValue)
21 |
22 | return inputValue
23 | }
24 |
25 | const parseLiteral = valueNode => {
26 | const { kind, value } = valueNode
27 |
28 | if (kind !== Kind.FLOAT && kind !== Kind.INT && kind !== Kind.STRING)
29 | throw getGraphQLValueError(ERROR_NON_NUMERIC_VALUE, valueNode)
30 |
31 | _validateIsDecimal(valueNode)
32 |
33 | return value
34 | }
35 |
36 | module.exports = new GraphQLScalarType({
37 | name: 'Decimal',
38 | description:
39 | 'The `Decimal` scalar type represents exact signed decimal values. Decimal represents values as strings rather than floating point numbers.',
40 | serialize,
41 | parseValue,
42 | parseLiteral
43 | })
44 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast/fromObject.js:
--------------------------------------------------------------------------------
1 | const { Kind } = require('graphql')
2 | const { isPlainObject } = require('../../utils')
3 |
4 | const _nullValue = { kind: Kind.NULL }
5 |
6 | const _valueToGraphQLType = value => {
7 | if (typeof value === 'boolean') return Kind.BOOLEAN
8 |
9 | if (typeof value === 'number') {
10 | if (Number.isInteger(value)) return Kind.INT
11 |
12 | return Kind.FLOAT
13 | }
14 |
15 | // Return below means: (typeof value === 'string' || Buffer.isBuffer(value))
16 | return Kind.STRING
17 | }
18 |
19 | const _valueToScalarValue = value => ({
20 | kind: _valueToGraphQLType(value),
21 | value,
22 | // Variable values have already been parsed
23 | // -> skip parsing in Argument and ObjectField visitor functions
24 | skipParsing: true
25 | })
26 |
27 | const _keyToName = key => ({
28 | kind: Kind.NAME,
29 | value: key
30 | })
31 |
32 | const _keyValueToObjectField = (k, v) => ({
33 | kind: Kind.OBJECT_FIELD,
34 | name: _keyToName(k),
35 | value: _variableToValue(v)
36 | })
37 |
38 | const _objectToObjectValue = object => ({
39 | kind: Kind.OBJECT,
40 | fields: Object.entries(object).map(([k, v]) => _keyValueToObjectField(k, v))
41 | })
42 |
43 | const _arrayToListValue = array => ({
44 | kind: Kind.LIST,
45 | values: array.map(a => _variableToValue(a))
46 | })
47 |
48 | const _variableToValue = variable => {
49 | if (Array.isArray(variable)) return _arrayToListValue(variable)
50 |
51 | if (isPlainObject(variable)) return _objectToObjectValue(variable)
52 |
53 | if (variable === null) return _nullValue
54 |
55 | if (variable === undefined) return undefined
56 |
57 | return _valueToScalarValue(variable)
58 | }
59 |
60 | module.exports = variableValue => _variableToValue(variableValue)
61 |
--------------------------------------------------------------------------------
/test/resources/bookshop/db/data/sap.capire.bookshop-Books_texts.csv:
--------------------------------------------------------------------------------
1 | ID;locale;title;descr
2 | 201;de;Sturmhöhe;Sturmhöhe (Originaltitel: Wuthering Heights) ist der einzige Roman der englischen Schriftstellerin Emily Brontë (1818–1848). Der 1847 unter dem Pseudonym Ellis Bell veröffentlichte Roman wurde vom viktorianischen Publikum weitgehend abgelehnt, heute gilt er als ein Klassiker der britischen Romanliteratur des 19. Jahrhunderts.
3 | 201;fr;Les Hauts de Hurlevent;Les Hauts de Hurlevent (titre original : Wuthering Heights), parfois orthographié Les Hauts de Hurle-Vent, est l'unique roman d'Emily Brontë, publié pour la première fois en 1847 sous le pseudonyme d’Ellis Bell. Loin d'être un récit moralisateur, Emily Brontë achève néanmoins le roman dans une atmosphère sereine, suggérant le triomphe de la paix et du Bien sur la vengeance et le Mal.
4 | 207;de;Jane Eyre;Jane Eyre. Eine Autobiographie (Originaltitel: Jane Eyre. An Autobiography), erstmals erschienen im Jahr 1847 unter dem Pseudonym Currer Bell, ist der erste veröffentlichte Roman der britischen Autorin Charlotte Brontë und ein Klassiker der viktorianischen Romanliteratur des 19. Jahrhunderts. Der Roman erzählt in Form einer Ich-Erzählung die Lebensgeschichte von Jane Eyre (ausgesprochen /ˌdʒeɪn ˈɛə/), die nach einer schweren Kindheit eine Stelle als Gouvernante annimmt und sich in ihren Arbeitgeber verliebt, jedoch immer wieder um ihre Freiheit und Selbstbestimmung kämpfen muss. Als klein, dünn, blass, stets schlicht dunkel gekleidet und mit strengem Mittelscheitel beschrieben, gilt die Heldin des Romans Jane Eyre nicht zuletzt aufgrund der Kino- und Fernsehversionen der melodramatischen Romanvorlage als die bekannteste englische Gouvernante der Literaturgeschichte
5 | 252;de;Eleonora;“Eleonora” ist eine Erzählung von Edgar Allan Poe. Sie wurde 1841 erstveröffentlicht. In ihr geht es um das Paradox der Treue in der Treulosigkeit.
--------------------------------------------------------------------------------
/test/util/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const { Kind } = require('graphql')
3 |
4 | const SCHEMAS_DIR = path.join(__dirname, '../schemas')
5 |
6 | /**
7 | * Create a fake/mock object that matches the structure of the info object that is passed to resolver functions by the graphql.js library.
8 | *
9 | * @param {Object} document - The parsed GraphQL query as returned by the graphql.js parse function.
10 | * @param {Object} [schema] - The GraphQL schema definition for the schema that the query adheres to.
11 | * @param {Object} [parentTypeName] - The name of the root type of the current query. Will most likely be 'Query' for queries and 'Mutation' for mutations.
12 | * @param {Object} [variables] - An object containing key/value pairs representing query variables and their values.
13 | * @returns {Object} Fake/mocked object that matches info object passed to resolvers.
14 | */
15 | const fakeInfoObject = (document, schema, parentTypeName, variables) => {
16 | const operationDefinition = document.definitions.find(d => d.kind === Kind.OPERATION_DEFINITION)
17 | const fragments = Object.fromEntries(
18 | document.definitions.filter(d => d.kind === Kind.FRAGMENT_DEFINITION).map(f => [f.name.value, f])
19 | )
20 | return {
21 | fieldNodes: operationDefinition.selectionSet.selections,
22 | schema,
23 | parentType: schema.getType(parentTypeName),
24 | variableValues: { ...variables },
25 | fragments
26 | }
27 | }
28 |
29 | /**
30 | * Dummy template literal tag function that returns the raw string that was passed to it.
31 | * Mocks the gql tag provided by the graphql-tag module and the graphql tag provided by the react-relay module.
32 | * Usage of this tag allows IDEs and prettier to detect GraphQL query strings and provide syntax highlighting and code formatting.
33 | */
34 | const gql = String.raw
35 |
36 | module.exports = { SCHEMAS_DIR, fakeInfoObject, gql }
37 |
--------------------------------------------------------------------------------
/lib/resolvers/crud/update.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const { SELECT, UPDATE } = cds.ql
3 | const { ARGS } = require('../../constants')
4 | const { getArgumentByName, astToColumns, astToWhere, astToEntries } = require('../parse/ast2cqn')
5 | const { entriesStructureToEntityStructure } = require('./utils')
6 | const GraphQLRequest = require('../GraphQLRequest')
7 | const formatResult = require('../parse/ast/result')
8 |
9 | module.exports = async ({ req, res }, service, entity, selection) => {
10 | const args = selection.arguments
11 |
12 | const filter = getArgumentByName(args, ARGS.filter)
13 |
14 | const queryBeforeUpdate = SELECT.from(entity)
15 | queryBeforeUpdate.columns(astToColumns(entity, selection.selectionSet.selections, false))
16 |
17 | if (filter) queryBeforeUpdate.where(astToWhere(filter))
18 |
19 | const query = UPDATE(entity)
20 |
21 | if (filter) query.where(astToWhere(filter))
22 |
23 | const input = getArgumentByName(args, ARGS.input)
24 | const entries = entriesStructureToEntityStructure(service, entity, astToEntries(input))
25 | query.with(entries)
26 |
27 | let resultBeforeUpdate
28 | const result = await service.tx(async tx => {
29 | // read needs to be done before the update, otherwise the where clause might become invalid (case that properties in where clause are updated by the mutation)
30 | resultBeforeUpdate = await tx.dispatch(new GraphQLRequest({ req, res, query: queryBeforeUpdate }))
31 | if (resultBeforeUpdate.length === 0) return {}
32 |
33 | return await tx.dispatch(new GraphQLRequest({ req, res, query }))
34 | })
35 |
36 | let mergedResults = result
37 | if (Array.isArray(resultBeforeUpdate)) {
38 | // Merge selected fields with updated data
39 | mergedResults = resultBeforeUpdate.map(original => ({ ...original, ...result }))
40 | }
41 |
42 | return formatResult(entity, selection, mergedResults, false)
43 | }
44 |
--------------------------------------------------------------------------------
/REUSE.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 | SPDX-PackageName = "graphql"
3 | SPDX-PackageSupplier = "The cap team "
4 | SPDX-PackageDownloadLocation = "https://github.com/cap-js/graphql"
5 | SPDX-PackageComment = "The code in this project may include calls to APIs (\"API Calls\") of\n SAP or third-party products or services developed outside of this project\n (\"External Products\").\n \"APIs\" means application programming interfaces, as well as their respective\n specifications and implementing code that allows software to communicate with\n other software.\n API Calls to External Products are not licensed under the open source license\n that governs this project. The use of such API Calls and related External\n Products are subject to applicable additional agreements with the relevant\n provider of the External Products. In no event shall the open source license\n that governs this project grant any rights in or to any External Products,or\n alter, expand or supersede any terms of the applicable additional agreements.\n If you have a valid license agreement with SAP for the use of a particular SAP\n External Product, then you may make use of any API Calls included in this\n project's code for that SAP External Product, subject to the terms of such\n license agreement. If you do not have a valid license agreement for the use of\n a particular SAP External Product, then you may only make use of any API Calls\n in this project for that SAP External Product for your internal, non-productive\n and non-commercial test and evaluation of such API Calls. Nothing herein grants\n you any rights to use or access any SAP External Product, or provide any third\n parties the right to use of access any SAP External Product, through API Calls."
6 |
7 | [[annotations]]
8 | path = "**"
9 | precedence = "aggregate"
10 | SPDX-FileCopyrightText = "2022 SAP SE or an SAP affiliate company and cap-js/graphql contributors"
11 | SPDX-License-Identifier = "Apache-2.0"
12 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast/enrich.js:
--------------------------------------------------------------------------------
1 | const { visit, Kind } = require('graphql')
2 | const fragmentSpreadSelections = require('./fragment')
3 | const substituteVariable = require('./variable')
4 | const parseLiteral = require('./literal')
5 |
6 | module.exports = info => {
7 | const rootTypeName = info.parentType.name
8 | const rootType = info.schema.getType(rootTypeName)
9 | const rootFields = rootType.getFields()
10 |
11 | const enrichedAST = visit(info.fieldNodes, {
12 | [Kind.SELECTION_SET](node) {
13 | // Substitute fragment spreads with fragment definitions into the AST as if they were inline fields
14 | // Prevents the necessity for special handling of fragments in AST to CQN
15 |
16 | // Note: FragmentSpread visitor function cannot be used to replace fragment spreads with fragment definitions
17 | // that contain multiple top level selections, since those must be placed directly into the selection set
18 | node.selections = fragmentSpreadSelections(info, node.selections)
19 | },
20 | [Kind.FIELD](node) {
21 | // Remove __typename from selections to prevent field from being interpreted as DB column
22 | // Instead let graphql framework determine the type
23 | if (node.name?.value === '__typename') return null
24 | },
25 | // Literals within the AST have not yet been parsed
26 | [Kind.ARGUMENT]: parseLiteral(rootFields),
27 | // Literals within the AST have not yet been parsed
28 | [Kind.OBJECT_FIELD]: parseLiteral(rootFields),
29 | [Kind.VARIABLE](node) {
30 | // Substitute variable values into the AST as if they were literal values
31 | // Prevents the necessity for special handling of variables in AST to CQN
32 | return substituteVariable(info, node)
33 | },
34 | [Kind.NULL](node) {
35 | // Convenience value for handling of null values in AST to CQN
36 | node.value = null
37 | }
38 | })
39 |
40 | return enrichedAST
41 | }
42 |
--------------------------------------------------------------------------------
/lib/schema/types/custom/GraphQLInt64.js:
--------------------------------------------------------------------------------
1 | const { GraphQLScalarType, Kind } = require('graphql')
2 | const { getValueFromInputValueOrValueNode, getGraphQLValueError, validateRange } = require('./util')
3 |
4 | const ERROR_VARIABLE_NON_STRING_VALUE = 'Int64 variable value must be represented by a string'
5 | const ERROR_NON_INTEGER_VALUE = 'Int64 cannot represent non integer value'
6 | const ERROR_NON_64_BIT_INTEGER_VALUE = 'Int64 must be an integer value between -(2^63) and 2^63 - 1'
7 |
8 | const MAX_INT64 = BigInt('9223372036854775807') // 2^63 - 1
9 | const MIN_INT64 = BigInt('-9223372036854775808') // (-2^63)
10 |
11 | const _toBigInt = inputValueOrValueNode => {
12 | const value = getValueFromInputValueOrValueNode(inputValueOrValueNode)
13 | if (value.trim().length === 0) throw getGraphQLValueError(ERROR_NON_INTEGER_VALUE, inputValueOrValueNode)
14 | try {
15 | return BigInt(value)
16 | } catch (error) {
17 | throw getGraphQLValueError(ERROR_NON_INTEGER_VALUE, inputValueOrValueNode)
18 | }
19 | }
20 |
21 | const parseValue = inputValue => {
22 | if (typeof inputValue !== 'string') throw getGraphQLValueError(ERROR_VARIABLE_NON_STRING_VALUE, inputValue)
23 |
24 | const num = _toBigInt(inputValue)
25 | validateRange(num, MIN_INT64, MAX_INT64, ERROR_NON_64_BIT_INTEGER_VALUE)
26 |
27 | return num.toString()
28 | }
29 |
30 | const parseLiteral = valueNode => {
31 | const { kind } = valueNode
32 |
33 | if (kind !== Kind.INT && kind !== Kind.STRING) throw getGraphQLValueError(ERROR_NON_INTEGER_VALUE, valueNode)
34 |
35 | const num = _toBigInt(valueNode)
36 | validateRange(num, MIN_INT64, MAX_INT64, ERROR_NON_64_BIT_INTEGER_VALUE, valueNode)
37 |
38 | return num.toString()
39 | }
40 |
41 | module.exports = new GraphQLScalarType({
42 | name: 'Int64',
43 | description:
44 | 'The `Int64` scalar type represents 64-bit non-fractional signed whole numeric values. Int64 can represent values between -(2^63) and 2^63 - 1.',
45 | parseValue,
46 | parseLiteral
47 | })
48 |
--------------------------------------------------------------------------------
/lib/schema/args/orderBy.js:
--------------------------------------------------------------------------------
1 | const { GraphQLList, GraphQLEnumType, GraphQLInputObjectType } = require('graphql')
2 | const { gqlName } = require('../../utils')
3 | const { hasScalarFields, shouldElementBeIgnored } = require('../util')
4 | const { cdsToGraphQLScalarType } = require('../types/scalar')
5 |
6 | module.exports = cache => {
7 | const generateOrderByForEntity = entity => {
8 | if (!hasScalarFields(entity)) return
9 |
10 | const orderByName = gqlName(entity.name) + '_orderBy'
11 |
12 | if (cache.has(orderByName)) return cache.get(orderByName)
13 |
14 | const fields = {}
15 | const orderByInputType = new GraphQLList(new GraphQLInputObjectType({ name: orderByName, fields: () => fields }))
16 | cache.set(orderByName, orderByInputType)
17 |
18 | for (const name in entity.elements) {
19 | const element = entity.elements[name]
20 | const type = generateOrderByForElement(element)
21 | if (type) fields[gqlName(name)] = { type }
22 | }
23 |
24 | return orderByInputType
25 | }
26 |
27 | const generateOrderByForElement = (element, followAssocOrComp) => {
28 | if (shouldElementBeIgnored(element)) return
29 |
30 | const gqlScalarType = cdsToGraphQLScalarType(element)
31 | if (followAssocOrComp && (element.isAssociation || element.isComposition)) {
32 | return gqlScalarType ? _generateSortDirectionEnum() : generateOrderByForEntity(element._target)
33 | } else if (gqlScalarType) {
34 | return _generateSortDirectionEnum()
35 | }
36 | }
37 |
38 | const _generateSortDirectionEnum = () => {
39 | const enumName = 'SortDirection'
40 |
41 | if (cache.has(enumName)) return cache.get(enumName)
42 |
43 | const sortDirectionEnum = new GraphQLEnumType({
44 | name: enumName,
45 | values: { asc: { value: 'asc' }, desc: { value: 'desc' } }
46 | })
47 | cache.set(enumName, sortDirectionEnum)
48 |
49 | return sortDirectionEnum
50 | }
51 |
52 | return { generateOrderByForEntity, generateOrderByForElement }
53 | }
54 |
--------------------------------------------------------------------------------
/lib/resolvers/root.js:
--------------------------------------------------------------------------------
1 | const { gqlName } = require('../utils')
2 | const resolveQuery = require('./query')
3 | const resolveMutation = require('./mutation')
4 | const { enrichAST } = require('./parse/ast')
5 | const { setResponse } = require('./response')
6 |
7 | const _wrapResolver = (service, resolver, parallel) =>
8 | async function CDSRootResolver(root, args, context, info) {
9 | const response = {}
10 |
11 | const enrichedFieldNodes = enrichAST(info)
12 |
13 | const _getResponse = field => {
14 | const fieldName = field.name.value
15 | const entity = service.entities[fieldName]
16 | const responseKey = field.alias?.value || fieldName
17 |
18 | const value = resolver(context, service, entity, field)
19 |
20 | return { key: responseKey, value }
21 | }
22 |
23 | const _getAndSetResponse = async field => {
24 | const { key, value } = _getResponse(field)
25 | await setResponse(context, response, key, value)
26 | }
27 |
28 | if (parallel) {
29 | await Promise.all(
30 | enrichedFieldNodes.map(fieldNode =>
31 | Promise.allSettled(fieldNode.selectionSet.selections.map(async field => await _getAndSetResponse(field)))
32 | )
33 | )
34 | } else {
35 | // REVISIT: should mutation resolvers run in same transaction or separate transactions?
36 | for (const fieldNode of enrichedFieldNodes) {
37 | for (const field of fieldNode.selectionSet.selections) {
38 | await _getAndSetResponse(field)
39 | }
40 | }
41 | }
42 |
43 | return response
44 | }
45 |
46 | module.exports = services => {
47 | const Query = {}
48 | const Mutation = {}
49 |
50 | for (const key in services) {
51 | const service = services[key]
52 | const gqlServiceName = gqlName(service.name)
53 | Query[gqlServiceName] = _wrapResolver(service, resolveQuery, true)
54 | Mutation[gqlServiceName] = _wrapResolver(service, resolveMutation, false)
55 | }
56 |
57 | return { Query, Mutation }
58 | }
59 |
--------------------------------------------------------------------------------
/lib/schema/args/input.js:
--------------------------------------------------------------------------------
1 | const { GraphQLList, GraphQLInputObjectType } = require('graphql')
2 | const { gqlName } = require('../../utils')
3 | const { shouldElementBeIgnored } = require('../util')
4 | const { cdsToGraphQLScalarType } = require('../types/scalar')
5 |
6 | module.exports = cache => {
7 | const entityToInputObjectType = (entity, isUpdate) => {
8 | const suffix = isUpdate ? '_U' : '_C'
9 | const entityName = gqlName(entity.name) + suffix
10 |
11 | if (cache.has(entityName)) return cache.get(entityName)
12 |
13 | const fields = {}
14 | const entityInputObjectType = new GraphQLInputObjectType({ name: entityName, fields: () => fields })
15 | cache.set(entityName, entityInputObjectType)
16 |
17 | for (const name in entity.elements) {
18 | const element = entity.elements[name]
19 | const type = _elementToInputObjectType(element, isUpdate)
20 | if (type) fields[gqlName(name)] = { type }
21 | }
22 |
23 | // fields is empty if update input object is generated for an entity that only contains key elements
24 | if (!Object.keys(fields).length) {
25 | cache.set(entityName)
26 | return
27 | }
28 |
29 | return entityInputObjectType
30 | }
31 |
32 | const _elementToInputObjectType = (element, isUpdate) => {
33 | if (shouldElementBeIgnored(element)) return
34 |
35 | // No keys in update input object
36 | if (isUpdate && element.key) return
37 |
38 | // TODO: @mandatory -> GraphQLNonNull
39 | const gqlScalarType = cdsToGraphQLScalarType(element)
40 | if (element.isAssociation || element.isComposition) {
41 | // Input objects in deep updates overwrite previous entries with new entries and therefore always act as create input objects
42 | const type = gqlScalarType || entityToInputObjectType(element._target, false)
43 | if (type) return element.is2one ? type : new GraphQLList(type)
44 | } else if (gqlScalarType) {
45 | return gqlScalarType
46 | }
47 | }
48 |
49 | return { entityToInputObjectType }
50 | }
51 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Code of Conduct
4 |
5 | All members of the project community must abide by the [Contributor Covenant, version 2.1](CODE_OF_CONDUCT.md).
6 | Only by respecting each other we can develop a productive, collaborative community.
7 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting [a project maintainer](.reuse/dep5).
8 |
9 | ## Engaging in Our Project
10 |
11 | We use GitHub to manage reviews of pull requests.
12 |
13 | * If you are a new contributor, see: [Steps to Contribute](#steps-to-contribute)
14 |
15 | * Before implementing your change, create an issue that describes the problem you would like to solve or the code that should be enhanced. Please note that you are willing to work on that issue.
16 |
17 | * The team will review the issue and decide whether it should be implemented as a pull request. In that case, they will assign the issue to you. If the team decides against picking up the issue, the team will post a comment with an explanation.
18 |
19 | ## Steps to Contribute
20 |
21 | Should you wish to work on an issue, please claim it first by commenting on the GitHub issue that you want to work on. This is to prevent duplicated efforts from other contributors on the same issue.
22 |
23 | If you have questions about one of the issues, please comment on them, and one of the maintainers will clarify.
24 |
25 | ## Contributing Code or Documentation
26 |
27 | You are welcome to contribute code in order to fix a bug or to implement a new feature that is logged as an issue.
28 |
29 | The following rule governs code contributions:
30 |
31 | * Contributions must be licensed under the [Apache 2.0 License](./LICENSE)
32 | * Due to legal reasons, contributors will be asked to accept a Developer Certificate of Origin (DCO) when they create the first pull request to this project. This happens in an automated fashion during the submission process. SAP uses [the standard DCO text of the Linux Foundation](https://developercertificate.org/).
33 |
34 | ## Issues and Planning
35 |
36 | * We use GitHub issues to track bugs and enhancement requests.
37 |
38 | * Please provide as much context as possible when you open an issue. The information you provide must be comprehensive enough to reproduce that issue for the assignee.
39 |
--------------------------------------------------------------------------------
/lib/schema/query.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const LOG = cds.log('graphql')
3 | const { GraphQLObjectType } = require('graphql')
4 | const { gqlName } = require('../utils')
5 | const objectGenerator = require('./types/object')
6 | const argsGenerator = require('./args')
7 | const { isCompositionOfAspect } = require('./util')
8 | const { GraphQLVoid } = require('./types/custom')
9 |
10 | module.exports = cache => {
11 | const generateQueryObjectType = (services, resolvers) => {
12 | const name = 'Query'
13 | const fields = {}
14 |
15 | for (const key in services) {
16 | const service = services[key]
17 | const serviceName = gqlName(service.name)
18 | const type = _serviceToObjectType(service)
19 | if (!type) continue
20 | fields[serviceName] = { type, resolve: resolvers[serviceName] }
21 | }
22 |
23 | // Empty root query object type is not allowed, so we add a placeholder field
24 | if (!Object.keys(fields).length) {
25 | fields._ = { type: GraphQLVoid }
26 | LOG.warn(
27 | `Root query object type "${name}" is empty. A placeholder field has been added to ensure a valid schema.`
28 | )
29 | }
30 |
31 | return new GraphQLObjectType({ name, fields })
32 | }
33 |
34 | const _serviceToObjectType = service => {
35 | const fields = {}
36 |
37 | for (const key in service.entities) {
38 | const entity = service.entities[key]
39 |
40 | if (isCompositionOfAspect(entity)) continue
41 |
42 | // REVISIT: requires differentiation for support of configurable schema flavors
43 | const type = objectGenerator(cache).entityToObjectConnectionType(entity)
44 | if (!type) continue
45 | const args = argsGenerator(cache).generateArgumentsForType(entity)
46 |
47 | fields[gqlName(key)] = { type, args }
48 | }
49 |
50 | if (!Object.keys(fields).length) {
51 | LOG.warn(`Service "${service.name}" has no fields and has therefore been excluded from the schema.`)
52 | return
53 | }
54 |
55 | return new GraphQLObjectType({
56 | name: gqlName(service.name),
57 | // REVISIT: Passed services currently don't directly contain doc property
58 | description: service.model.definitions[service.name].doc,
59 | fields
60 | })
61 | }
62 |
63 | return { generateQueryObjectType }
64 | }
65 |
--------------------------------------------------------------------------------
/test/resources/error-handling/srv/custom-handler-errors.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 |
3 | module.exports = srv => {
4 | srv.on('READ', 'A', () => {
5 | const error = new Error('Error on READ A')
6 | Object.assign(error, {
7 | myProperty: 'My value A1',
8 | $myProperty: 'My value A2',
9 | my: { nested: { property: 'My value A3' } },
10 | $my: { nested: { property: 'My value A4' } }
11 | })
12 | throw error
13 | })
14 |
15 | srv.on('READ', 'B', () => {
16 | throw 'Error on READ B'
17 | })
18 |
19 | srv.on('READ', 'C', () => {
20 | throw new cds.error('Error on READ C')
21 | })
22 |
23 | srv.on('READ', 'D', () => {
24 | cds.error('MY_CODE', { code: 'MY_CODE', myProperty: 'My value D1', $myProperty: 'My value D2' })
25 | })
26 |
27 | srv.on('READ', 'E', async req => {
28 | req.error({
29 | code: 'Some-Custom-Code',
30 | message: 'Some Custom Error Message',
31 | target: 'some_field',
32 | status: 418
33 | })
34 | })
35 |
36 | srv.on('READ', 'F', async req => {
37 | req.error({
38 | code: 'Some-Custom-Code1',
39 | message: 'Some Custom Error Message 1',
40 | target: 'some_field',
41 | status: 418
42 | })
43 | req.error({
44 | code: 'Some-Custom-Code2',
45 | message: 'Some Custom Error Message 2',
46 | target: 'some_field',
47 | status: 500
48 | })
49 | })
50 |
51 | srv.on('READ', 'G', async req => {
52 | // 'modify' property is checked in service level error handler
53 | req.error({ code: '418', message: 'Error on READ G', modify: true })
54 | })
55 |
56 | srv.on('error', err => {
57 | // 'modify' property is set in error thrown by READ G handler
58 | if (err.modify) {
59 | err.message = 'Oh no! ' + err.message
60 | err.myProperty = 'My value G1'
61 | err.$myProperty = 'My value G2'
62 | delete err.modify
63 | }
64 | })
65 |
66 | srv.before('CREATE', 'Orders', async req => {
67 | const { id, quantity, stock } = req.data
68 | if (quantity > stock) {
69 | const code = 'ORDER_EXCEEDS_STOCK'
70 | const message = code
71 | const args = [quantity, quantity - stock]
72 |
73 | if (id === 1) req.reject(400, message, args)
74 | if (id === 2) req.reject(code, message, args)
75 | }
76 | })
77 | }
78 |
--------------------------------------------------------------------------------
/test/resources/bookshop/readme.md:
--------------------------------------------------------------------------------
1 | # Bookshop Getting Started Sample
2 |
3 | This stand-alone sample introduces the essential tasks in the development of CAP-based services as also covered in the [Getting Started guide in capire](https://cap.cloud.sap/docs/get-started/in-a-nutshell).
4 |
5 | ## Hypothetical Use Cases
6 |
7 | 1. Build a service that allows to browse _Books_ and _Authors_.
8 | 2. Books have assigned _Genres_, which are organized hierarchically.
9 | 3. All users may browse books without login.
10 | 4. All entries are maintained by Administrators.
11 | 5. End users may order books (the actual order mgmt being out of scope).
12 |
13 | ## Running the Sample
14 |
15 | ```sh
16 | npm run watch
17 | ```
18 |
19 | ## Content & Best Practices
20 |
21 | | Links to capire | Sample files / folders |
22 | | --------------------------------------------------------------------------------------------------------- | ------------------------------------ |
23 | | [Project Setup & Layouts](https://cap.cloud.sap/docs/get-started/projects#sharing-and-reusing-content) | [`./`](./) |
24 | | [Domain Modeling with CDS](https://cap.cloud.sap/docs/guides/domain-models) | [`./db/schema.cds`](./db/schema.cds) |
25 | | [Defining Services](https://cap.cloud.sap/docs/guides/services#defining-services) | [`./srv/*.cds`](./srv) |
26 | | [Single-purposed Services](https://cap.cloud.sap/docs/guides/services#single-purposed-services) | [`./srv/*.cds`](./srv) |
27 | | [Providing & Consuming Providers](https://cap.cloud.sap/docs/guides/providing-services) | http://localhost:4004 |
28 | | [Using Databases](https://cap.cloud.sap/docs/guides/databases) | [`./db/data/*.csv`](./db/data) |
29 | | [Adding Custom Logic](https://cap.cloud.sap/docs/guides/service-impl) | [`./srv/*.js`](./srv) |
30 | | Adding Tests | [`./test`](./test) |
31 | | [Sharing for Reuse](https://cap.cloud.sap/docs/guides/reuse-and-compose) | [`./index.cds`](./index.cds) |
32 |
--------------------------------------------------------------------------------
/test/tests/edge-cases.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - edge cases', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../util')
5 |
6 | const { axios, POST, data } = cds.test(path.join(__dirname, '../resources/edge-cases'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 |
10 | beforeEach(async () => {
11 | await data.reset()
12 | })
13 |
14 | test('no name clashes occur between CDS names and connection fields with 2 one', async () => {
15 | const queryCreate = gql`
16 | mutation {
17 | FieldsWithConnectionNamesService {
18 | Root {
19 | create(input: { totalCount: "foo", nodes: { totalCount: "bar", nodes: "baz" } }) {
20 | totalCount
21 | nodes {
22 | totalCount
23 | nodes
24 | }
25 | }
26 | }
27 | }
28 | }
29 | `
30 | const dataCreate = {
31 | FieldsWithConnectionNamesService: {
32 | Root: {
33 | create: [
34 | {
35 | totalCount: 'foo',
36 | nodes: {
37 | totalCount: 'bar',
38 | nodes: 'baz'
39 | }
40 | }
41 | ]
42 | }
43 | }
44 | }
45 | const responseCreate = await POST('/graphql', { query: queryCreate })
46 | expect(responseCreate.data).toEqual({ data: dataCreate })
47 |
48 | const queryRead = gql`
49 | {
50 | FieldsWithConnectionNamesService {
51 | Root {
52 | totalCount
53 | nodes {
54 | totalCount
55 | nodes {
56 | totalCount
57 | nodes
58 | }
59 | }
60 | }
61 | }
62 | }
63 | `
64 | const dataRead = {
65 | FieldsWithConnectionNamesService: {
66 | Root: {
67 | totalCount: 1,
68 | nodes: [
69 | {
70 | totalCount: 'foo',
71 | nodes: {
72 | totalCount: 'bar',
73 | nodes: 'baz'
74 | }
75 | }
76 | ]
77 | }
78 | }
79 | }
80 | const responseRead = await POST('/graphql', { query: queryRead })
81 | expect(responseRead.data).toEqual({ data: dataRead })
82 | })
83 | })
84 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast/result.js:
--------------------------------------------------------------------------------
1 | const { CONNECTION_FIELDS } = require('../../../constants')
2 | const { isPlainObject } = require('../../utils')
3 | const { getPotentiallyNestedNodesSelections } = require('../util')
4 |
5 | const formatResult = (entity, field, result, isConnection) => {
6 | const _formatObject = (type, selections, object) => {
7 | const result = {}
8 |
9 | for (const key in object) {
10 | const value = object[key]
11 |
12 | const fields = selections.filter(s => s.alias?.value === key || s.name.value === key)
13 |
14 | for (const field of fields) {
15 | const element = type.elements[field.name.value]
16 | const elementType = element._target || element
17 | const responseKey = field.alias?.value || field.name.value
18 | result[responseKey] = _formatByType(elementType, field, value, element.is2many)
19 | }
20 | }
21 |
22 | return result
23 | }
24 |
25 | const _aliasFieldsWithValue = (selections, name, value) =>
26 | selections
27 | .filter(selection => selection.name.value === name)
28 | .reduce((acc, selection) => {
29 | const responseKey = selection.alias?.value || selection.name.value
30 | acc[responseKey] = value
31 | return acc
32 | }, {})
33 |
34 | const _formatArray = (type, field, array, isConnection) => {
35 | const selections = field.selectionSet.selections
36 | const potentiallyNestedSelections = getPotentiallyNestedNodesSelections(selections, isConnection)
37 | const result = array.map(e => _formatObject(type, potentiallyNestedSelections, e))
38 |
39 | // REVISIT: requires differentiation for support of configurable schema flavors
40 | if (!isConnection) return result
41 |
42 | return {
43 | ..._aliasFieldsWithValue(selections, CONNECTION_FIELDS.nodes, result),
44 | ..._aliasFieldsWithValue(selections, CONNECTION_FIELDS.totalCount, result.$count)
45 | }
46 | }
47 |
48 | const _formatByType = (type, field, value, isConnection) => {
49 | if (Array.isArray(value)) return _formatArray(type, field, value, isConnection)
50 |
51 | if (isPlainObject(value)) return _formatObject(type, field.selectionSet.selections, value)
52 |
53 | return value
54 | }
55 |
56 | // TODO: if singleton, don't wrap in array
57 | const resultInArray = isPlainObject(result) ? [result] : result
58 | return _formatByType(entity, field, resultInArray, isConnection)
59 | }
60 |
61 | module.exports = formatResult
62 |
--------------------------------------------------------------------------------
/test/resources/bookshop/app/vue/app.js:
--------------------------------------------------------------------------------
1 | /* global Vue axios */ //> from vue.html
2 | const $ = sel => document.querySelector(sel)
3 | const GET = (url) => axios.get('/browse'+url)
4 | const POST = (cmd,data) => axios.post('/browse'+cmd,data)
5 |
6 | const books = Vue.createApp ({
7 |
8 | data() {
9 | return {
10 | list: [],
11 | book: undefined,
12 | order: { quantity:1, succeeded:'', failed:'' },
13 | user: undefined
14 | }
15 | },
16 |
17 | methods: {
18 |
19 | search: ({target:{value:v}}) => books.fetch(v && '&$search='+v),
20 |
21 | async fetch (etc='') {
22 | const {data} = await GET(`/ListOfBooks?$expand=genre,currency${etc}`)
23 | books.list = data.value
24 | },
25 |
26 | async inspect (eve) {
27 | const book = books.book = books.list [eve.currentTarget.rowIndex-1]
28 | const res = await GET(`/Books/${book.ID}?$select=descr,stock,image`)
29 | Object.assign (book, res.data)
30 | books.order = { quantity:1 }
31 | setTimeout (()=> $('form > input').focus(), 111)
32 | },
33 |
34 | async submitOrder () {
35 | const {book,order} = books, quantity = parseInt (order.quantity) || 1 // REVISIT: Okra should be less strict
36 | try {
37 | const res = await POST(`/submitOrder`, { quantity, book: book.ID })
38 | book.stock = res.data.stock
39 | books.order = { quantity, succeeded: `Successfully ordered ${quantity} item(s).` }
40 | } catch (e) {
41 | books.order = { quantity, failed: e.response.data.error ? e.response.data.error.message : e.response.data }
42 | }
43 | },
44 |
45 | async login() {
46 | try {
47 | const { data:user } = await axios.post('/user/login',{})
48 | if (user.id !== 'anonymous') books.user = user
49 | } catch (err) { books.user = { id: err.message } }
50 | },
51 |
52 | async getUserInfo() {
53 | try {
54 | const { data:user } = await axios.get('/user/me')
55 | if (user.id !== 'anonymous') books.user = user
56 | } catch (err) { books.user = { id: err.message } }
57 | },
58 | }
59 | }).mount("#app")
60 |
61 | books.getUserInfo()
62 | books.fetch() // initially fill list of books
63 |
64 | document.addEventListener('keydown', (event) => {
65 | // hide user info on request
66 | if (event.key === 'u') books.user = undefined
67 | })
68 |
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast/literal.js:
--------------------------------------------------------------------------------
1 | const { Kind } = require('graphql')
2 |
3 | const _getTypeFrom_fieldOr_arg = _field => {
4 | let { type } = _field
5 | while (type.ofType) type = type.ofType
6 | return type
7 | }
8 |
9 | const _getTypeFrom_fields = (_fields, path, index = 0) => {
10 | const { name } = path[index++]
11 |
12 | const _field = _fields[name]
13 | const type = _getTypeFrom_fieldOr_arg(_field)
14 |
15 | // If we are at the end of the path, this field is a leaf and therefore is of scalar type with a parseLiteral function
16 | if (index === path.length) return type
17 |
18 | const next = path[index]
19 | // Is the next path element an argument? If yes, follow the argument
20 | if (next.kind === Kind.ARGUMENT) {
21 | const arg = _field.args.find(a => a.name === next.name)
22 | const type = _getTypeFrom_fieldOr_arg(arg)
23 |
24 | // If type has the parseLiteral function it is a scalar type -> leaf -> end of path
25 | // This case occurs when the argument itself is a scalar type, e.g. Books(top: 1)
26 | if (type.parseLiteral) return type
27 |
28 | return _getTypeFrom_fields(type.getFields(), path, index + 1)
29 | }
30 |
31 | return _getTypeFrom_fields(type.getFields(), path, index)
32 | }
33 |
34 | const _pathKinds = [Kind.FIELD, Kind.ARGUMENT, Kind.OBJECT_FIELD]
35 | const _isNodePathKind = node => _pathKinds.includes(node.kind)
36 | // Note: no array methods used to avoid needlessly copying the array
37 | const _simplifiedPath = (node, ancestors) => {
38 | const path = []
39 |
40 | for (const ancestor of ancestors) {
41 | if (!_isNodePathKind(ancestor)) continue
42 | path.push({ kind: ancestor.kind, name: ancestor.name.value })
43 | }
44 |
45 | if (_isNodePathKind(node)) path.push({ kind: node.kind, name: node.name.value })
46 |
47 | return path
48 | }
49 |
50 | const _scalarKinds = [Kind.INT, Kind.FLOAT, Kind.STRING, Kind.BOOLEAN]
51 | const _isScalarKind = _scalarKinds.includes.bind(_scalarKinds)
52 |
53 | // Literals are provided unparsed within the AST, contrary to variable values
54 | const parseLiteral = rootFields => (node, _key, _parent, _path, ancestors) => {
55 | const { value } = node
56 | if (!_isScalarKind(value.kind)) return
57 |
58 | // Set for variable values that have been substituted into the AST, which are already parsed
59 | if (value.skipParsing) {
60 | delete value.skipParsing
61 | return
62 | }
63 |
64 | const simplifiedPath = _simplifiedPath(node, ancestors)
65 | const type = _getTypeFrom_fields(rootFields, simplifiedPath)
66 | value.value = type.parseLiteral(value)
67 | }
68 |
69 | module.exports = parseLiteral
70 |
--------------------------------------------------------------------------------
/lib/logger.js:
--------------------------------------------------------------------------------
1 | const express = require('express')
2 | const router = express.Router()
3 | const cds = require('@sap/cds')
4 | const { decodeURIComponent } = cds.utils
5 | const { IS_PRODUCTION } = require('./utils')
6 | const util = require('util')
7 | const LOG = cds.log('graphql')
8 | const { parse, visit, Kind, print } = require('graphql')
9 |
10 | const _isEmptyObject = o => Object.keys(o).length === 0
11 | class InvalidJSON extends Error {}
12 | InvalidJSON.prototype.name = 'Invalid JSON body'
13 | InvalidJSON.prototype.status = 400
14 |
15 | router.use(function jsonBodyParser(req, res, next) {
16 | express.json({ ...cds.env.server.body_parser })(req, res, function http_body_parser_next(err) {
17 | // Need to wrap, as CAP server deliberately crashes on SyntaxErrors
18 | if (err) return next(new InvalidJSON(err.message))
19 | next()
20 | })
21 | })
22 |
23 | router.use(function queryLogger(req, _, next) {
24 | let query = req.body?.query || (req.query.query && decodeURIComponent(req.query.query))
25 | // Only log requests that contain a query
26 | if (!query) {
27 | next()
28 | return
29 | }
30 |
31 | const operationName = req.body?.operationName || req.query?.operationName
32 |
33 | let variables = req.body?.variables || req.query?.variables
34 | if (typeof variables === 'string') {
35 | try {
36 | // variables is a JSON string if taken from req.query.variables
37 | variables = JSON.parse(variables)
38 | } catch (e) {
39 | // Ignore parsing errors, handled by GraphQL server
40 | }
41 | }
42 | if (IS_PRODUCTION && variables && !_isEmptyObject(variables)) variables = '***'
43 |
44 | // Only add properties to object that aren't undefined or empty
45 | const queryInfo = {
46 | ...(operationName && { operationName }),
47 | ...(variables && !_isEmptyObject(variables) && { variables })
48 | }
49 | // Only format queryInfo if it contains properties
50 | const formattedQueryInfo = _isEmptyObject(queryInfo)
51 | ? undefined
52 | : util.formatWithOptions({ colors: false, depth: null }, queryInfo)
53 |
54 | query = query.trim()
55 |
56 | if (IS_PRODUCTION) {
57 | try {
58 | const ast = parse(query)
59 | // Sanitize all arguments unless they are variables
60 | visit(ast, {
61 | [Kind.ARGUMENT](node) {
62 | if (node.value.kind === Kind.VARIABLE) return
63 | node.value = { kind: Kind.STRING, value: '***' }
64 | }
65 | })
66 | query = print(ast)
67 | } catch {
68 | // If parsing or sanitizing the query fails, log the original query
69 | }
70 | }
71 |
72 | // Don't log undefined values
73 | LOG.info(...[req.method, formattedQueryInfo, '\n', query].filter(e => e))
74 |
75 | next()
76 | })
77 |
78 | module.exports = router
79 |
--------------------------------------------------------------------------------
/test/tests/context.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - context is set', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../util')
5 |
6 | const { axios, POST } = cds.test(path.join(__dirname, '../resources/bookshop-graphql'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 |
10 | beforeEach(async () => {
11 | const deleteAll = gql`
12 | mutation {
13 | TestService {
14 | Foo {
15 | delete
16 | }
17 | }
18 | }
19 | `
20 | await POST('/graphql', { query: deleteAll })
21 | const insertOne = gql`
22 | mutation {
23 | TestService {
24 | Foo {
25 | create(input: { ID: 1, bar: "baz" }) {
26 | bar
27 | }
28 | }
29 | }
30 | }
31 | `
32 | await POST('/graphql', { query: insertOne })
33 | })
34 |
35 | test('read', async () => {
36 | const query = gql`
37 | {
38 | TestService {
39 | Foo {
40 | nodes {
41 | ID
42 | bar
43 | }
44 | }
45 | }
46 | }
47 | `
48 | const response = await POST('/graphql', { query })
49 | expect(response.data).toEqual({ data: { TestService: { Foo: { nodes: [{ ID: 1, bar: 'baz' }] } } } })
50 | })
51 |
52 | test('create', async () => {
53 | const query = gql`
54 | mutation {
55 | TestService {
56 | Foo {
57 | create(input: { ID: 2, bar: "boo" }) {
58 | ID
59 | bar
60 | }
61 | }
62 | }
63 | }
64 | `
65 | const response = await POST('/graphql', { query })
66 | expect(response.data).toEqual({
67 | data: { TestService: { Foo: { create: [{ ID: 2, bar: 'boo' }] } } }
68 | })
69 | })
70 |
71 | test('update', async () => {
72 | const query = gql`
73 | mutation {
74 | TestService {
75 | Foo {
76 | update(filter: { ID: { eq: 1 } }, input: { bar: "boo" }) {
77 | ID
78 | bar
79 | }
80 | }
81 | }
82 | }
83 | `
84 | const response = await POST('/graphql', { query })
85 | expect(response.data).toEqual({
86 | data: { TestService: { Foo: { update: [{ ID: 1, bar: 'boo' }] } } }
87 | })
88 | })
89 |
90 | test('delete', async () => {
91 | const query = gql`
92 | mutation {
93 | TestService {
94 | Foo {
95 | delete(filter: { ID: { eq: 1 } })
96 | }
97 | }
98 | }
99 | `
100 | const response = await POST('/graphql', { query })
101 | expect(response.data).toEqual({ data: { TestService: { Foo: { delete: 1 } } } })
102 | })
103 | })
104 |
--------------------------------------------------------------------------------
/test/resources/bookshop/db/data/sap.capire.bookshop-Books.csv:
--------------------------------------------------------------------------------
1 | ID;title;descr;author_ID;stock;price;currency_code;genre_ID
2 | 201;Wuthering Heights;"Wuthering Heights, Emily Brontë's only novel, was published in 1847 under the pseudonym ""Ellis Bell"". It was written between October 1845 and June 1846. Wuthering Heights and Anne Brontë's Agnes Grey were accepted by publisher Thomas Newby before the success of their sister Charlotte's novel Jane Eyre. After Emily's death, Charlotte edited the manuscript of Wuthering Heights and arranged for the edited version to be published as a posthumous second edition in 1850.";101;12;11.11;GBP;11
3 | 207;Jane Eyre;"Jane Eyre /ɛər/ (originally published as Jane Eyre: An Autobiography) is a novel by English writer Charlotte Brontë, published under the pen name ""Currer Bell"", on 16 October 1847, by Smith, Elder & Co. of London. The first American edition was published the following year by Harper & Brothers of New York. Primarily a bildungsroman, Jane Eyre follows the experiences of its eponymous heroine, including her growth to adulthood and her love for Mr. Rochester, the brooding master of Thornfield Hall. The novel revolutionised prose fiction in that the focus on Jane's moral and spiritual development is told through an intimate, first-person narrative, where actions and events are coloured by a psychological intensity. The book contains elements of social criticism, with a strong sense of Christian morality at its core and is considered by many to be ahead of its time because of Jane's individualistic character and how the novel approaches the topics of class, sexuality, religion and feminism.";107;11;12.34;GBP;11
4 | 251;The Raven;"""The Raven"" is a narrative poem by American writer Edgar Allan Poe. First published in January 1845, the poem is often noted for its musicality, stylized language, and supernatural atmosphere. It tells of a talking raven's mysterious visit to a distraught lover, tracing the man's slow fall into madness. The lover, often identified as being a student, is lamenting the loss of his love, Lenore. Sitting on a bust of Pallas, the raven seems to further distress the protagonist with its constant repetition of the word ""Nevermore"". The poem makes use of folk, mythological, religious, and classical references.";150;333;13.13;USD;16
5 | 252;Eleonora;"""Eleonora"" is a short story by Edgar Allan Poe, first published in 1842 in Philadelphia in the literary annual The Gift. It is often regarded as somewhat autobiographical and has a relatively ""happy"" ending.";150;555;14;USD;16
6 | 271;Catweazle;Catweazle is a British fantasy television series, starring Geoffrey Bayldon in the title role, and created by Richard Carpenter for London Weekend Television. The first series, produced and directed by Quentin Lawrence, was screened in the UK on ITV in 1970. The second series, directed by David Reid and David Lane, was shown in 1971. Each series had thirteen episodes, most but not all written by Carpenter, who also published two books based on the scripts.;170;22;150;JPY;13
--------------------------------------------------------------------------------
/test/resources/bookshop/app/vue/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Capire Books
6 |
7 |
8 |
9 |
16 |
17 |
18 |
19 |
20 |
21 |
32 |
33 |
Capire Books
34 |
35 |
36 |
37 |
38 |
39 | | Book |
40 | Author |
41 | Genre |
42 | Rating |
43 | Price |
44 |
45 |
46 | | {{ book.title }} |
47 | {{ book.author }} |
48 | {{ book.genre.name }} |
49 |
50 | {{ ('★'.repeat(Math.round(book.rating))+'☆☆☆☆☆').slice(0,5) }} ({{ book.numberOfReviews }})
51 | |
52 | {{ book.currency && book.currency.symbol }} {{ book.price }} |
53 |
54 |
55 |
56 |
57 |
![]()
58 |
63 |
67 |
{{ book.title }}
68 |
{{ book.descr }}
69 |
70 |
71 | ( click on a row to see details... )
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://api.reuse.software/info/github.com/cap-js/graphql)
2 |
3 | # CDS protocol adapter for GraphQL
4 |
5 | ## About this project
6 |
7 | A GraphQL protocol adapter for [SAP Cloud Application Programming Model](https://cap.cloud.sap) Node.js.
8 | This adapter generically generates a GraphQL schema for the models of an application and serves an endpoint that allows you to query your services using the GraphQL query language.
9 |
10 | _**WARNING:** This package is in an early general availability state. This means that it is generally available, with stable APIs unless otherwise indicated, and you can use it for production. However, please note the [current limitations](#limitations) listed below._
11 |
12 | ## Requirements and Setup
13 |
14 | 1. Simply add the GraphQL adapter to your project using `npm`:
15 | ```js
16 | npm add @cap-js/graphql
17 | ```
18 |
19 | > This command will set up the GraphQL plug-in with the `@sap/cds` runtime. It enables the new [middlewares architecture](https://cap.cloud.sap/docs/node.js/middlewares) in Node.js and registers a GraphQL endpoint at `/graphql` serving all CRUD requests for the application services found in your model.
20 |
21 | 2. Annotate the services you want to serve, e.g. using `@graphql` or `@protocol: 'graphql'`.
22 |
23 | 3. Run your server as usual, e.g. using `cds watch`.
24 | > The runtime will serve all annotated services via GraphQL at the default configured endpoint.
25 |
26 | ## Limitations
27 |
28 | - **Actions** and functions are not yet supported.
29 | - **CDS annotations** like `@readonly` aren’t considered during schema generation.
30 | - **Cursor-based Pagination** – we currently support offset-based pagination, and will add cursor-based pagination going forward. While we intend to support both variants then, it is not guaranteed that we can do so without breaking changes to current behaviour.
31 | - **Extensions** are not yet considered.
32 |
33 | ## Support, Feedback, Contributing
34 |
35 | This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/graphql/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md).
36 |
37 | ## Code of Conduct
38 |
39 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times.
40 |
41 | ## Licensing
42 |
43 | Copyright 2022 SAP SE or an SAP affiliate company and cap-js/graphql contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/graphql).
44 |
--------------------------------------------------------------------------------
/lib/schema/types/object.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const LOG = cds.log('graphql')
3 | const { GraphQLObjectType, GraphQLList, GraphQLInt } = require('graphql')
4 | const { gqlName } = require('../../utils')
5 | const argsGenerator = require('../args')
6 | const { shouldElementBeIgnored } = require('../util')
7 | const { cdsToGraphQLScalarType } = require('../types/scalar')
8 | const { CONNECTION_FIELDS } = require('../../constants')
9 |
10 | module.exports = cache => {
11 | const entityToObjectConnectionType = entity => {
12 | const name = gqlName(entity.name) + '_connection'
13 |
14 | if (cache.has(name)) return cache.get(name)
15 |
16 | const fields = {}
17 | const entityObjectConnectionType = new GraphQLObjectType({ name, fields: () => fields })
18 | cache.set(name, entityObjectConnectionType)
19 |
20 | const objectType = entityToObjectType(entity)
21 | if (!objectType) {
22 | cache.set(name)
23 | return
24 | }
25 |
26 | fields[CONNECTION_FIELDS.nodes] = { type: new GraphQLList(objectType) }
27 | fields[CONNECTION_FIELDS.totalCount] = { type: GraphQLInt }
28 |
29 | return entityObjectConnectionType
30 | }
31 |
32 | const entityToObjectType = entity => {
33 | const entityName = gqlName(entity.name)
34 |
35 | if (cache.has(entityName)) return cache.get(entityName)
36 |
37 | const fields = {}
38 | const entityObjectType = new GraphQLObjectType({
39 | name: entityName,
40 | description: entity.doc,
41 | fields: () => fields
42 | })
43 | cache.set(entityName, entityObjectType)
44 |
45 | for (const name in entity.elements) {
46 | const element = entity.elements[name]
47 |
48 | // REVISIT: requires differentiation for support of configurable schema flavors
49 | const type = element.is2many ? _elementToObjectConnectionType(element) : _elementToObjectType(element)
50 | if (!type) continue
51 |
52 | const field = { type, description: element.doc }
53 | if (element.is2many) field.args = argsGenerator(cache).generateArgumentsForType(element)
54 | fields[gqlName(name)] = field
55 | }
56 |
57 | // fields is empty e.g. for empty aspects
58 | if (!Object.keys(fields).length) {
59 | LOG.warn(`Entity "${entity.name}" has no fields and has therefore been excluded from the schema.`)
60 | cache.set(entityName, undefined)
61 | return
62 | }
63 |
64 | return entityObjectType
65 | }
66 |
67 | const _elementToObjectType = element => {
68 | if (shouldElementBeIgnored(element)) return
69 |
70 | const gqlScalarType = cdsToGraphQLScalarType(element)
71 | if (element.isAssociation || element.isComposition) {
72 | const type = gqlScalarType || entityToObjectType(element._target)
73 | return element.is2one ? type : new GraphQLList(type)
74 | } else if (gqlScalarType) {
75 | return gqlScalarType
76 | }
77 | }
78 |
79 | const _elementToObjectConnectionType = element => {
80 | if (shouldElementBeIgnored(element)) return
81 |
82 | const gqlScalarType = cdsToGraphQLScalarType(element._type)
83 | return entityToObjectConnectionType(gqlScalarType || element._target)
84 | }
85 |
86 | return { entityToObjectConnectionType, entityToObjectType }
87 | }
88 |
--------------------------------------------------------------------------------
/test/schemas/model-structure/composition-of-aspect.gql:
--------------------------------------------------------------------------------
1 | type CompositionOfAspectService {
2 | Books(filter: [CompositionOfAspectService_Books_filter], orderBy: [CompositionOfAspectService_Books_orderBy], skip: Int, top: Int): CompositionOfAspectService_Books_connection
3 | }
4 |
5 | type CompositionOfAspectService_Books {
6 | chapters(filter: [CompositionOfAspectService_Books_chapters_filter], orderBy: [CompositionOfAspectService_Books_chapters_orderBy], skip: Int, top: Int): CompositionOfAspectService_Books_chapters_connection
7 | id: ID
8 | reviews(filter: [CompositionOfAspectService_Books_reviews_filter], orderBy: [CompositionOfAspectService_Books_reviews_orderBy], skip: Int, top: Int): CompositionOfAspectService_Books_reviews_connection
9 | }
10 |
11 | input CompositionOfAspectService_Books_C {
12 | chapters: [CompositionOfAspectService_Books_chapters_C]
13 | id: ID
14 | reviews: [CompositionOfAspectService_Books_reviews_C]
15 | }
16 |
17 | input CompositionOfAspectService_Books_U {
18 | chapters: [CompositionOfAspectService_Books_chapters_C]
19 | reviews: [CompositionOfAspectService_Books_reviews_C]
20 | }
21 |
22 | type CompositionOfAspectService_Books_chapters {
23 | id: ID
24 | }
25 |
26 | input CompositionOfAspectService_Books_chapters_C {
27 | id: ID
28 | }
29 |
30 | type CompositionOfAspectService_Books_chapters_connection {
31 | nodes: [CompositionOfAspectService_Books_chapters]
32 | totalCount: Int
33 | }
34 |
35 | input CompositionOfAspectService_Books_chapters_filter {
36 | id: [ID_filter]
37 | }
38 |
39 | input CompositionOfAspectService_Books_chapters_orderBy {
40 | id: SortDirection
41 | }
42 |
43 | type CompositionOfAspectService_Books_connection {
44 | nodes: [CompositionOfAspectService_Books]
45 | totalCount: Int
46 | }
47 |
48 | input CompositionOfAspectService_Books_filter {
49 | id: [ID_filter]
50 | }
51 |
52 | type CompositionOfAspectService_Books_input {
53 | create(input: [CompositionOfAspectService_Books_C]!): [CompositionOfAspectService_Books]
54 | delete(filter: [CompositionOfAspectService_Books_filter]!): Int
55 | update(filter: [CompositionOfAspectService_Books_filter]!, input: CompositionOfAspectService_Books_U!): [CompositionOfAspectService_Books]
56 | }
57 |
58 | input CompositionOfAspectService_Books_orderBy {
59 | id: SortDirection
60 | }
61 |
62 | type CompositionOfAspectService_Books_reviews {
63 | id: ID
64 | }
65 |
66 | input CompositionOfAspectService_Books_reviews_C {
67 | id: ID
68 | }
69 |
70 | type CompositionOfAspectService_Books_reviews_connection {
71 | nodes: [CompositionOfAspectService_Books_reviews]
72 | totalCount: Int
73 | }
74 |
75 | input CompositionOfAspectService_Books_reviews_filter {
76 | id: [ID_filter]
77 | }
78 |
79 | input CompositionOfAspectService_Books_reviews_orderBy {
80 | id: SortDirection
81 | }
82 |
83 | type CompositionOfAspectService_input {
84 | Books: CompositionOfAspectService_Books_input
85 | }
86 |
87 | input ID_filter {
88 | eq: ID
89 | ge: ID
90 | gt: ID
91 | in: [ID]
92 | le: ID
93 | lt: ID
94 | ne: [ID]
95 | }
96 |
97 | type Mutation {
98 | CompositionOfAspectService: CompositionOfAspectService_input
99 | }
100 |
101 | type Query {
102 | CompositionOfAspectService: CompositionOfAspectService
103 | }
104 |
105 | enum SortDirection {
106 | asc
107 | desc
108 | }
--------------------------------------------------------------------------------
/lib/resolvers/parse/ast2cqn/where.js:
--------------------------------------------------------------------------------
1 | const { RELATIONAL_OPERATORS, LOGICAL_OPERATORS, STRING_OPERATIONS } = require('../../../constants')
2 | const { Kind } = require('graphql')
3 |
4 | const GQL_TO_CDS_STRING_OPERATIONS = {
5 | [STRING_OPERATIONS.startswith]: 'startswith',
6 | [STRING_OPERATIONS.endswith]: 'endswith',
7 | [STRING_OPERATIONS.contains]: 'contains'
8 | }
9 |
10 | const GQL_TO_CDS_QL_OPERATOR = {
11 | [RELATIONAL_OPERATORS.eq]: '=',
12 | [RELATIONAL_OPERATORS.ne]: '!=',
13 | [RELATIONAL_OPERATORS.gt]: '>',
14 | [RELATIONAL_OPERATORS.ge]: '>=',
15 | [RELATIONAL_OPERATORS.le]: '<=',
16 | [RELATIONAL_OPERATORS.lt]: '<',
17 | [LOGICAL_OPERATORS.in]: 'in'
18 | }
19 |
20 | const _gqlOperatorToCdsOperator = gqlOperator =>
21 | GQL_TO_CDS_QL_OPERATOR[gqlOperator] || GQL_TO_CDS_STRING_OPERATIONS[gqlOperator]
22 |
23 | const _to_xpr = (ref, gqlOperator, value) => {
24 | const cdsOperator = _gqlOperatorToCdsOperator(gqlOperator)
25 | const val = { val: value }
26 |
27 | if (STRING_OPERATIONS[gqlOperator]) return [{ func: cdsOperator, args: [ref, val] }]
28 | return [ref, cdsOperator, val]
29 | }
30 |
31 | const _objectFieldTo_xpr = (objectField, columnName) => {
32 | const ref = { ref: [columnName] }
33 | const gqlOperator = objectField.name.value
34 | const operand = objectField.value
35 |
36 | if (gqlOperator === LOGICAL_OPERATORS.in) {
37 | const list =
38 | operand.kind === Kind.LIST ? operand.values.map(value => ({ val: value.value })) : [{ val: operand.value }]
39 | return [ref, _gqlOperatorToCdsOperator(gqlOperator), { list }]
40 | }
41 |
42 | if (operand.kind === Kind.LIST) {
43 | const _xprs = operand.values.map(value => _to_xpr(ref, gqlOperator, value.value))
44 | return _joinedXprFrom_xprs(_xprs, 'and')
45 | }
46 |
47 | return _to_xpr(ref, gqlOperator, operand.value)
48 | }
49 |
50 | const _parseObjectField = (objectField, columnName) => {
51 | if (columnName) return _objectFieldTo_xpr(objectField, columnName)
52 |
53 | const value = objectField.value
54 | const name = objectField.name.value
55 | switch (value.kind) {
56 | case Kind.LIST:
57 | return _parseListValue(value, name)
58 | case Kind.OBJECT:
59 | return _parseObjectValue(value, name)
60 | }
61 | }
62 |
63 | const _arrayInsertBetweenFlat = (array, element) =>
64 | array.flatMap((e, index) => (index === array.length - 1 ? [e] : [e, element])).flat()
65 |
66 | const _joinedXprFrom_xprs = (_xprs, operator) => ({ xpr: _arrayInsertBetweenFlat(_xprs, operator) })
67 |
68 | const _true_xpr = [{ val: '1' }, '=', { val: '1' }]
69 |
70 | const _parseObjectValue = (objectValue, columnName) => {
71 | const _xprs = objectValue.fields
72 | .map(field => _parseObjectField(field, columnName))
73 | .filter(field => field !== undefined)
74 | if (_xprs.length === 0) return _true_xpr
75 | else if (_xprs.length === 1) return _xprs[0]
76 | return _joinedXprFrom_xprs(_xprs, 'and')
77 | }
78 |
79 | const _false_xpr = [{ val: '0' }, '=', { val: '1' }]
80 |
81 | const _parseListValue = (listValue, columnName) => {
82 | const _xprs = listValue.values.map(value => _parseObjectValue(value, columnName)).filter(value => value !== undefined)
83 | if (_xprs.length === 0) return _false_xpr
84 | else if (_xprs.length === 1) return _xprs[0]
85 | return _joinedXprFrom_xprs(_xprs, 'or')
86 | }
87 |
88 | const astToWhere = filterArg => {
89 | const value = filterArg.value
90 | switch (value.kind) {
91 | case Kind.LIST:
92 | return _parseListValue(value)
93 | case Kind.OBJECT:
94 | return _parseObjectValue(value)
95 | }
96 | }
97 |
98 | module.exports = astToWhere
99 |
--------------------------------------------------------------------------------
/test/schemas/edge-cases/field-named-localized.gql:
--------------------------------------------------------------------------------
1 | type FieldNamedLocalizedService {
2 | Root(filter: [FieldNamedLocalizedService_Root_filter], orderBy: [FieldNamedLocalizedService_Root_orderBy], skip: Int, top: Int): FieldNamedLocalizedService_Root_connection
3 | localized(filter: [FieldNamedLocalizedService_localized_filter], orderBy: [FieldNamedLocalizedService_localized_orderBy], skip: Int, top: Int): FieldNamedLocalizedService_localized_connection
4 | }
5 |
6 | type FieldNamedLocalizedService_Root {
7 | ID: Int
8 | localized(filter: [FieldNamedLocalizedService_localized_filter], orderBy: [FieldNamedLocalizedService_localized_orderBy], skip: Int, top: Int): FieldNamedLocalizedService_localized_connection
9 | }
10 |
11 | input FieldNamedLocalizedService_Root_C {
12 | ID: Int
13 | localized: [FieldNamedLocalizedService_localized_C]
14 | }
15 |
16 | input FieldNamedLocalizedService_Root_U {
17 | localized: [FieldNamedLocalizedService_localized_C]
18 | }
19 |
20 | type FieldNamedLocalizedService_Root_connection {
21 | nodes: [FieldNamedLocalizedService_Root]
22 | totalCount: Int
23 | }
24 |
25 | input FieldNamedLocalizedService_Root_filter {
26 | ID: [Int_filter]
27 | }
28 |
29 | type FieldNamedLocalizedService_Root_input {
30 | create(input: [FieldNamedLocalizedService_Root_C]!): [FieldNamedLocalizedService_Root]
31 | delete(filter: [FieldNamedLocalizedService_Root_filter]!): Int
32 | update(filter: [FieldNamedLocalizedService_Root_filter]!, input: FieldNamedLocalizedService_Root_U!): [FieldNamedLocalizedService_Root]
33 | }
34 |
35 | input FieldNamedLocalizedService_Root_orderBy {
36 | ID: SortDirection
37 | }
38 |
39 | type FieldNamedLocalizedService_input {
40 | Root: FieldNamedLocalizedService_Root_input
41 | localized: FieldNamedLocalizedService_localized_input
42 | }
43 |
44 | type FieldNamedLocalizedService_localized {
45 | ID: Int
46 | localized: String
47 | root: FieldNamedLocalizedService_Root
48 | }
49 |
50 | input FieldNamedLocalizedService_localized_C {
51 | ID: Int
52 | localized: String
53 | root: FieldNamedLocalizedService_Root_C
54 | }
55 |
56 | input FieldNamedLocalizedService_localized_U {
57 | localized: String
58 | root: FieldNamedLocalizedService_Root_C
59 | }
60 |
61 | type FieldNamedLocalizedService_localized_connection {
62 | nodes: [FieldNamedLocalizedService_localized]
63 | totalCount: Int
64 | }
65 |
66 | input FieldNamedLocalizedService_localized_filter {
67 | ID: [Int_filter]
68 | localized: [String_filter]
69 | }
70 |
71 | type FieldNamedLocalizedService_localized_input {
72 | create(input: [FieldNamedLocalizedService_localized_C]!): [FieldNamedLocalizedService_localized]
73 | delete(filter: [FieldNamedLocalizedService_localized_filter]!): Int
74 | update(filter: [FieldNamedLocalizedService_localized_filter]!, input: FieldNamedLocalizedService_localized_U!): [FieldNamedLocalizedService_localized]
75 | }
76 |
77 | input FieldNamedLocalizedService_localized_orderBy {
78 | ID: SortDirection
79 | localized: SortDirection
80 | }
81 |
82 | input Int_filter {
83 | eq: Int
84 | ge: Int
85 | gt: Int
86 | in: [Int]
87 | le: Int
88 | lt: Int
89 | ne: [Int]
90 | }
91 |
92 | type Mutation {
93 | FieldNamedLocalizedService: FieldNamedLocalizedService_input
94 | }
95 |
96 | type Query {
97 | FieldNamedLocalizedService: FieldNamedLocalizedService
98 | }
99 |
100 | enum SortDirection {
101 | asc
102 | desc
103 | }
104 |
105 | input String_filter {
106 | contains: [String]
107 | endswith: String
108 | eq: String
109 | ge: String
110 | gt: String
111 | in: [String]
112 | le: String
113 | lt: String
114 | ne: [String]
115 | startswith: String
116 | }
--------------------------------------------------------------------------------
/test/schemas/edge-cases/fields-with-connection-names.gql:
--------------------------------------------------------------------------------
1 | type FieldsWithConnectionNamesService {
2 | Nodes(filter: [FieldsWithConnectionNamesService_Nodes_filter], orderBy: [FieldsWithConnectionNamesService_Nodes_orderBy], skip: Int, top: Int): FieldsWithConnectionNamesService_Nodes_connection
3 | Root(filter: [FieldsWithConnectionNamesService_Root_filter], orderBy: [FieldsWithConnectionNamesService_Root_orderBy], skip: Int, top: Int): FieldsWithConnectionNamesService_Root_connection
4 | }
5 |
6 | type FieldsWithConnectionNamesService_Nodes {
7 | ID: ID
8 | nodes: String
9 | totalCount: String
10 | }
11 |
12 | input FieldsWithConnectionNamesService_Nodes_C {
13 | ID: ID
14 | nodes: String
15 | totalCount: String
16 | }
17 |
18 | input FieldsWithConnectionNamesService_Nodes_U {
19 | nodes: String
20 | totalCount: String
21 | }
22 |
23 | type FieldsWithConnectionNamesService_Nodes_connection {
24 | nodes: [FieldsWithConnectionNamesService_Nodes]
25 | totalCount: Int
26 | }
27 |
28 | input FieldsWithConnectionNamesService_Nodes_filter {
29 | ID: [ID_filter]
30 | nodes: [String_filter]
31 | totalCount: [String_filter]
32 | }
33 |
34 | type FieldsWithConnectionNamesService_Nodes_input {
35 | create(input: [FieldsWithConnectionNamesService_Nodes_C]!): [FieldsWithConnectionNamesService_Nodes]
36 | delete(filter: [FieldsWithConnectionNamesService_Nodes_filter]!): Int
37 | update(filter: [FieldsWithConnectionNamesService_Nodes_filter]!, input: FieldsWithConnectionNamesService_Nodes_U!): [FieldsWithConnectionNamesService_Nodes]
38 | }
39 |
40 | input FieldsWithConnectionNamesService_Nodes_orderBy {
41 | ID: SortDirection
42 | nodes: SortDirection
43 | totalCount: SortDirection
44 | }
45 |
46 | type FieldsWithConnectionNamesService_Root {
47 | ID: ID
48 | nodes: FieldsWithConnectionNamesService_Nodes
49 | totalCount: String
50 | }
51 |
52 | input FieldsWithConnectionNamesService_Root_C {
53 | ID: ID
54 | nodes: FieldsWithConnectionNamesService_Nodes_C
55 | totalCount: String
56 | }
57 |
58 | input FieldsWithConnectionNamesService_Root_U {
59 | nodes: FieldsWithConnectionNamesService_Nodes_C
60 | totalCount: String
61 | }
62 |
63 | type FieldsWithConnectionNamesService_Root_connection {
64 | nodes: [FieldsWithConnectionNamesService_Root]
65 | totalCount: Int
66 | }
67 |
68 | input FieldsWithConnectionNamesService_Root_filter {
69 | ID: [ID_filter]
70 | totalCount: [String_filter]
71 | }
72 |
73 | type FieldsWithConnectionNamesService_Root_input {
74 | create(input: [FieldsWithConnectionNamesService_Root_C]!): [FieldsWithConnectionNamesService_Root]
75 | delete(filter: [FieldsWithConnectionNamesService_Root_filter]!): Int
76 | update(filter: [FieldsWithConnectionNamesService_Root_filter]!, input: FieldsWithConnectionNamesService_Root_U!): [FieldsWithConnectionNamesService_Root]
77 | }
78 |
79 | input FieldsWithConnectionNamesService_Root_orderBy {
80 | ID: SortDirection
81 | totalCount: SortDirection
82 | }
83 |
84 | type FieldsWithConnectionNamesService_input {
85 | Nodes: FieldsWithConnectionNamesService_Nodes_input
86 | Root: FieldsWithConnectionNamesService_Root_input
87 | }
88 |
89 | input ID_filter {
90 | eq: ID
91 | ge: ID
92 | gt: ID
93 | in: [ID]
94 | le: ID
95 | lt: ID
96 | ne: [ID]
97 | }
98 |
99 | type Mutation {
100 | FieldsWithConnectionNamesService: FieldsWithConnectionNamesService_input
101 | }
102 |
103 | type Query {
104 | FieldsWithConnectionNamesService: FieldsWithConnectionNamesService
105 | }
106 |
107 | enum SortDirection {
108 | asc
109 | desc
110 | }
111 |
112 | input String_filter {
113 | contains: [String]
114 | endswith: String
115 | eq: String
116 | ge: String
117 | gt: String
118 | in: [String]
119 | le: String
120 | lt: String
121 | ne: [String]
122 | startswith: String
123 | }
--------------------------------------------------------------------------------
/lib/schema/mutation.js:
--------------------------------------------------------------------------------
1 | const { GraphQLObjectType, GraphQLList, GraphQLInt, GraphQLNonNull } = require('graphql')
2 | const { gqlName } = require('../utils')
3 | const objectGenerator = require('./types/object')
4 | const filterGenerator = require('./args/filter')
5 | const inputObjectGenerator = require('./args/input')
6 | const { ARGS } = require('../constants')
7 | const { isCompositionOfAspect } = require('./util')
8 |
9 | module.exports = cache => {
10 | const generateMutationObjectType = (services, resolvers) => {
11 | const fields = {}
12 |
13 | for (const key in services) {
14 | const service = services[key]
15 | const serviceName = gqlName(service.name)
16 | const resolve = resolvers[serviceName]
17 | const type = _serviceToObjectType(service)
18 | if (type) fields[serviceName] = { type, resolve }
19 | }
20 |
21 | if (!Object.keys(fields).length) return
22 |
23 | return new GraphQLObjectType({ name: 'Mutation', fields })
24 | }
25 |
26 | const _serviceToObjectType = service => {
27 | const fields = {}
28 |
29 | for (const key in service.entities) {
30 | const entity = service.entities[key]
31 |
32 | if (isCompositionOfAspect(entity)) continue
33 |
34 | const entityName = gqlName(key)
35 | const type = _entityToObjectType(entity)
36 | if (type) fields[entityName] = { type }
37 | }
38 |
39 | if (!Object.keys(fields).length) return
40 |
41 | return new GraphQLObjectType({ name: gqlName(service.name) + '_input', fields })
42 | }
43 |
44 | const _entityToObjectType = entity => {
45 | // Filter out undefined fields
46 | const fields = Object.fromEntries(
47 | Object.entries({
48 | create: _create(entity),
49 | update: _update(entity),
50 | delete: _delete(entity)
51 | }).filter(([_, v]) => v)
52 | )
53 |
54 | if (!Object.keys(fields).length) return
55 |
56 | return new GraphQLObjectType({ name: gqlName(entity.name) + '_input', fields })
57 | }
58 |
59 | const _create = entity => {
60 | const entityObjectType = objectGenerator(cache).entityToObjectType(entity)
61 |
62 | const createInputObjectType = inputObjectGenerator(cache).entityToInputObjectType(entity, false)
63 | if (!createInputObjectType) return
64 |
65 | const args = {
66 | [ARGS.input]: { type: new GraphQLNonNull(new GraphQLList(createInputObjectType)) }
67 | }
68 | return { type: new GraphQLList(entityObjectType), args }
69 | }
70 |
71 | const _update = entity => {
72 | const entityObjectType = objectGenerator(cache).entityToObjectType(entity)
73 |
74 | const filterInputObjectType = filterGenerator(cache).generateFilterForEntity(entity)
75 | const updateInputObjectType = inputObjectGenerator(cache).entityToInputObjectType(entity, true)
76 | // filterInputObjectType is undefined if the entity only contains elements that are associations or compositions
77 | // updateInputObjectType is undefined if it is generated for an entity that only contains key elements
78 | if (!filterInputObjectType || !updateInputObjectType) return
79 |
80 | const args = {
81 | [ARGS.filter]: { type: new GraphQLNonNull(filterInputObjectType) },
82 | [ARGS.input]: { type: new GraphQLNonNull(updateInputObjectType) }
83 | }
84 | return { type: new GraphQLList(entityObjectType), args }
85 | }
86 |
87 | const _delete = entity => {
88 | const filterInputObjectType = filterGenerator(cache).generateFilterForEntity(entity)
89 | // filterInputObjectType is undefined if the entity only contains elements that are associations or compositions
90 | if (!filterInputObjectType) return
91 |
92 | const args = {
93 | [ARGS.filter]: { type: new GraphQLNonNull(filterInputObjectType) }
94 | }
95 | return { type: GraphQLInt, args }
96 | }
97 |
98 | return { generateMutationObjectType }
99 | }
100 |
--------------------------------------------------------------------------------
/lib/schema/args/filter.js:
--------------------------------------------------------------------------------
1 | const {
2 | GraphQLList,
3 | GraphQLString,
4 | GraphQLBoolean,
5 | GraphQLFloat,
6 | GraphQLInt,
7 | GraphQLID,
8 | GraphQLInputObjectType
9 | } = require('graphql')
10 | const { gqlName } = require('../../utils')
11 | const { hasScalarFields, shouldElementBeIgnored } = require('../util')
12 | const { cdsToGraphQLScalarType } = require('../types/scalar')
13 | const { RELATIONAL_OPERATORS, LOGICAL_OPERATORS, STRING_OPERATIONS, OPERATOR_LIST_SUPPORT } = require('../../constants')
14 | const {
15 | GraphQLBinary,
16 | GraphQLDate,
17 | GraphQLDateTime,
18 | GraphQLInt16,
19 | GraphQLInt64,
20 | GraphQLDecimal,
21 | GraphQLTime,
22 | GraphQLTimestamp,
23 | GraphQLUInt8
24 | } = require('../types/custom')
25 |
26 | module.exports = cache => {
27 | const generateFilterForEntity = entity => {
28 | if (!hasScalarFields(entity)) return
29 |
30 | const filterName = gqlName(entity.name) + '_filter'
31 |
32 | if (cache.has(filterName)) return cache.get(filterName)
33 |
34 | const fields = {}
35 | const filterInputType = new GraphQLList(new GraphQLInputObjectType({ name: filterName, fields: () => fields }))
36 | cache.set(filterName, filterInputType)
37 |
38 | for (const name in entity.elements) {
39 | const element = entity.elements[name]
40 | const type = generateFilterForElement(element)
41 | if (type) fields[gqlName(name)] = { type }
42 | }
43 |
44 | return filterInputType
45 | }
46 |
47 | const generateFilterForElement = (element, followAssocOrComp) => {
48 | if (shouldElementBeIgnored(element)) return
49 |
50 | const gqlScalarType = cdsToGraphQLScalarType(element)
51 | if (followAssocOrComp && (element.isAssociation || element.isComposition)) {
52 | return gqlScalarType ? _generateScalarFilter(gqlScalarType) : generateFilterForEntity(element._target)
53 | } else if (gqlScalarType) {
54 | return _generateScalarFilter(gqlScalarType)
55 | }
56 | }
57 |
58 | const _generateScalarFilter = gqlType => {
59 | const numericOperators = Object.values({ ...RELATIONAL_OPERATORS, ...LOGICAL_OPERATORS })
60 | const filterType = {
61 | // REVISIT: which filters for binary
62 | [GraphQLBinary.name]: _generateFilterType(GraphQLBinary, [RELATIONAL_OPERATORS.eq, RELATIONAL_OPERATORS.ne]),
63 | [GraphQLBoolean.name]: _generateFilterType(GraphQLBoolean, [RELATIONAL_OPERATORS.eq, RELATIONAL_OPERATORS.ne]),
64 | [GraphQLDate.name]: _generateFilterType(GraphQLDate, numericOperators),
65 | [GraphQLDateTime.name]: _generateFilterType(GraphQLDateTime, numericOperators),
66 | [GraphQLDecimal.name]: _generateFilterType(GraphQLDecimal, numericOperators),
67 | // REVISIT: should 'eq'/'ne'/'in' be generated since exact comparisons could be difficult due to floating point errors?
68 | [GraphQLFloat.name]: _generateFilterType(GraphQLFloat, numericOperators),
69 | [GraphQLID.name]: _generateFilterType(GraphQLID, numericOperators),
70 | [GraphQLInt.name]: _generateFilterType(GraphQLInt, numericOperators),
71 | [GraphQLInt16.name]: _generateFilterType(GraphQLInt16, numericOperators),
72 | [GraphQLInt64.name]: _generateFilterType(GraphQLInt64, numericOperators),
73 | [GraphQLString.name]: _generateFilterType(
74 | GraphQLString,
75 | Object.values({ ...RELATIONAL_OPERATORS, ...LOGICAL_OPERATORS, ...STRING_OPERATIONS })
76 | ),
77 | [GraphQLTime.name]: _generateFilterType(GraphQLTime, numericOperators),
78 | [GraphQLTimestamp.name]: _generateFilterType(GraphQLTimestamp, numericOperators),
79 | [GraphQLUInt8.name]: _generateFilterType(GraphQLUInt8, numericOperators)
80 | }[gqlType.name]
81 | return new GraphQLList(filterType)
82 | }
83 |
84 | const _generateFilterType = (gqlType, operations) => {
85 | const filterName = gqlType.name + '_filter'
86 |
87 | if (cache.has(filterName)) return cache.get(filterName)
88 |
89 | const ops = operations.map(op => [[op], { type: OPERATOR_LIST_SUPPORT[op] ? new GraphQLList(gqlType) : gqlType }])
90 | const fields = Object.fromEntries(ops)
91 | const filterType = new GraphQLInputObjectType({ name: filterName, fields })
92 | cache.set(filterName, filterType)
93 |
94 | return filterType
95 | }
96 |
97 | return { generateFilterForEntity, generateFilterForElement }
98 | }
99 |
--------------------------------------------------------------------------------
/test/tests/request.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - cds.request', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../util')
5 |
6 | const { axios, POST } = cds.test(path.join(__dirname, '../resources/cds.Request'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 |
10 | describe('HTTP request headers are correctly passed to custom handlers', () => {
11 | const my_header = 'my header value'
12 |
13 | test('Create', async () => {
14 | const query = gql`
15 | mutation {
16 | RequestService {
17 | A {
18 | create(input: {}) {
19 | my_header
20 | }
21 | }
22 | }
23 | }
24 | `
25 | const data = {
26 | RequestService: {
27 | A: {
28 | create: [{ my_header }]
29 | }
30 | }
31 | }
32 | const response = await POST('/graphql', { query }, { headers: { my_header } })
33 | expect(response.data).toEqual({ data })
34 | })
35 |
36 | test('Read', async () => {
37 | const query = gql`
38 | {
39 | RequestService {
40 | A {
41 | nodes {
42 | my_header
43 | }
44 | }
45 | }
46 | }
47 | `
48 | const data = {
49 | RequestService: {
50 | A: {
51 | nodes: [{ my_header }]
52 | }
53 | }
54 | }
55 | const response = await POST('/graphql', { query }, { headers: { my_header } })
56 | expect(response.data).toEqual({ data })
57 | })
58 |
59 | test('Update', async () => {
60 | const query = gql`
61 | mutation {
62 | RequestService {
63 | A {
64 | update(filter: [], input: {}) {
65 | my_header
66 | }
67 | }
68 | }
69 | }
70 | `
71 | const data = {
72 | RequestService: {
73 | A: {
74 | update: [{ my_header }]
75 | }
76 | }
77 | }
78 | const response = await POST('/graphql', { query }, { headers: { my_header } })
79 | expect(response.data).toEqual({ data })
80 | })
81 |
82 | test('Delete', async () => {
83 | const query = gql`
84 | mutation {
85 | RequestService {
86 | A {
87 | delete(filter: [])
88 | }
89 | }
90 | }
91 | `
92 | const data = {
93 | RequestService: {
94 | A: {
95 | delete: 999
96 | }
97 | }
98 | }
99 | const response = await POST('/graphql', { query }, { headers: { my_header } })
100 | expect(response.data).toEqual({ data })
101 | })
102 | })
103 |
104 | describe('HTTP response headers are correctly set in custom handlers', () => {
105 | const my_res_header = 'my res header value'
106 |
107 | test('Create', async () => {
108 | const query = gql`
109 | mutation {
110 | RequestService {
111 | A {
112 | create(input: {}) {
113 | id
114 | }
115 | }
116 | }
117 | }
118 | `
119 | const response = await POST('/graphql', { query })
120 | expect(response.headers).toMatchObject({ my_res_header })
121 | })
122 |
123 | test('Read', async () => {
124 | const query = gql`
125 | {
126 | RequestService {
127 | A {
128 | nodes {
129 | id
130 | }
131 | }
132 | }
133 | }
134 | `
135 | const response = await POST('/graphql', { query })
136 | expect(response.headers).toMatchObject({ my_res_header })
137 | })
138 |
139 | test('Update', async () => {
140 | const query = gql`
141 | mutation {
142 | RequestService {
143 | A {
144 | update(filter: [], input: {}) {
145 | id
146 | }
147 | }
148 | }
149 | }
150 | `
151 | const response = await POST('/graphql', { query })
152 | expect(response.headers).toMatchObject({ my_res_header })
153 | })
154 |
155 | test('Delete', async () => {
156 | const query = gql`
157 | mutation {
158 | RequestService {
159 | A {
160 | delete(filter: [])
161 | }
162 | }
163 | }
164 | `
165 | const response = await POST('/graphql', { query })
166 | expect(response.headers).toMatchObject({ my_res_header })
167 | })
168 | })
169 | })
170 |
--------------------------------------------------------------------------------
/lib/resolvers/error.js:
--------------------------------------------------------------------------------
1 | const cds = require('@sap/cds')
2 | const { i18n } = cds
3 | const LOG_CDS = cds.log()
4 | const LOG_GRAPHQL = cds.log('graphql')
5 | const { GraphQLError } = require('graphql')
6 | const { IS_PRODUCTION } = require('../utils')
7 |
8 | // FIXME: importing internal modules from @sap/cds is discouraged and not recommended for external usage
9 | const { normalizeError } = require('@sap/cds/libx/odata/middleware/error')
10 |
11 | const _applyToJSON = error => {
12 | // Make stack enumerable so it isn't stripped by toJSON function
13 | if (error.stack) Object.defineProperty(error, 'stack', { value: error.stack, enumerable: true })
14 | // Call toJSON function to apply i18n
15 | return error.toJSON ? error.toJSON() : error
16 | }
17 |
18 | const _reorderProperties = error => {
19 | // 'stack' and 'stacktrace' to cover both common cases that a custom error formatter might return
20 | let { code, message, details, stack, stacktrace } = error
21 | if (details) details = details.map(_reorderProperties)
22 | return { code, message, ...error, stack, stacktrace, details }
23 | }
24 |
25 | const _cdsToGraphQLError = (context, err) => {
26 | const { req, errorFormatter } = context
27 | let error = normalizeError(err, req, false)
28 |
29 | error = _applyToJSON(error)
30 | if (error.details) error.details = error.details.map(_applyToJSON)
31 |
32 | // In case of 5xx errors in production don't reveal details to clients
33 | if (IS_PRODUCTION && error.status >= 500 && error.$sanitize !== false)
34 | error = {
35 | message: i18n.messages.at(error.status, cds.context.locale) || 'Internal Server Error',
36 | code: String(error.status) // toJSON is intentionally gone
37 | }
38 |
39 | // Apply error formatter to error details first if they exist, then apply error formatter to outer error
40 | if (error.details) error.details = error.details.map(errorFormatter)
41 | error = errorFormatter(error)
42 |
43 | // Ensure error properties are ordered nicely for the client
44 | error = _reorderProperties(error)
45 |
46 | const { message } = error
47 | const extensions = error
48 |
49 | // Top level message is already passed to GraphQLError to be used beside extensions -> not needed in extensions
50 | delete extensions.message
51 |
52 | const graphQLError = new GraphQLError(message, { extensions })
53 |
54 | return Object.defineProperty(graphQLError, '_cdsError', { value: true, writable: false, enumerable: false })
55 | }
56 |
57 | const _clone = obj => Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj))
58 |
59 | // TODO: Revise this logging functionality, as it's not specific to protocol adapters.
60 | // This function should be relocated and/or cleaned up when the new abstract/generic
61 | // protocol adapter is designed and implemented.
62 | const _log = (error, req) => {
63 | // log errors and warnings only
64 | if (LOG_CDS.level <= cds.log.levels.WARN) return
65 |
66 | // Clone of the original error object to prevent mutation and unintended side-effects.
67 | // Notice that the cloned error is logged to standard output in its default language,
68 | // whereas the original error message is locale-dependent as it is usually sent in the
69 | // HTTP response to HTTP Clients to be displayed in the user interface.
70 | let error2log = _clone(error)
71 | if (error.details) {
72 | error2log.details = error.details.map(error => _clone(error))
73 |
74 | // Excluding the stack trace for the outer error as the inner stack trace already
75 | // contains the initial segment of the outer stack trace.
76 | delete error2log.stack
77 | }
78 |
79 | error2log = normalizeError(error2log, { __proto__: req, locale: '' }, false)
80 |
81 | // determine if the status code represents a client error (4xx range)
82 | if (error2log.status >= 400 && error2log.status < 500) {
83 | if (LOG_CDS._warn) LOG_CDS.warn(error2log)
84 | } else {
85 | // server error
86 | if (LOG_CDS._error) LOG_CDS.error(error2log)
87 | }
88 | }
89 |
90 | const _ensureError = error => (error instanceof Error ? error : new Error(error))
91 |
92 | const handleCDSError = (context, error) => {
93 | error = _ensureError(error)
94 | _log(error, context.req)
95 | return _cdsToGraphQLError(context, error)
96 | }
97 |
98 | const formatError = error => {
99 | // Note: error is not always an instance of GraphQLError
100 |
101 | // CDS errors have already been logged and already have a stacktrace in extensions
102 | if (error.originalError?._cdsError) return error
103 |
104 | if (LOG_GRAPHQL._error) LOG_GRAPHQL.error(error)
105 |
106 | // error does not have an extensions property when it is not an instance of GraphQLError
107 | if (!IS_PRODUCTION && error.extensions) error.extensions.stacktrace = error.stack.split('\n')
108 |
109 | return error
110 | }
111 |
112 | module.exports = { handleCDSError, formatError }
113 |
--------------------------------------------------------------------------------
/test/tests/annotations.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - annotations', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../util')
5 |
6 | const { axios, POST } = cds.test(path.join(__dirname, '../resources/annotations'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 |
10 | describe('protocols annotations', () => {
11 | const path = '/custom-graphql-path'
12 |
13 | test('service not annotated is not served', async () => {
14 | const query = gql`
15 | {
16 | NotAnnotated {
17 | A {
18 | nodes {
19 | id
20 | }
21 | }
22 | }
23 | }
24 | `
25 | const response = await POST(path, { query })
26 | expect(response.data.errors[0].message).toMatch(/^Cannot query field "NotAnnotated" on type "Query"\./)
27 | })
28 |
29 | test('service annotated with "@protocol: \'none\'" is not served', async () => {
30 | const query = gql`
31 | {
32 | AnnotatedWithAtProtocolNone {
33 | A {
34 | nodes {
35 | id
36 | }
37 | }
38 | }
39 | }
40 | `
41 | const response = await POST(path, { query })
42 | expect(response.data.errors[0].message).toMatch(
43 | /^Cannot query field "AnnotatedWithAtProtocolNone" on type "Query"\./
44 | )
45 | })
46 |
47 | test('service annotated with non-GraphQL protocol is not served', async () => {
48 | const query = gql`
49 | {
50 | AnnotatedWithNonGraphQL {
51 | A {
52 | nodes {
53 | id
54 | }
55 | }
56 | }
57 | }
58 | `
59 | const response = await POST(path, { query })
60 | expect(response.data.errors[0].message).toMatch(/^Cannot query field "AnnotatedWithNonGraphQL" on type "Query"\./)
61 | })
62 |
63 | test('service annotated with @graphql is served at configured path', async () => {
64 | const query = gql`
65 | {
66 | AnnotatedWithAtGraphQL {
67 | A {
68 | nodes {
69 | id
70 | }
71 | }
72 | }
73 | }
74 | `
75 | const response = await POST(path, { query })
76 | expect(response.data).not.toHaveProperty('errors')
77 | })
78 |
79 | test('service annotated with "@protocol: \'graphql\'" is served at configured path', async () => {
80 | const query = gql`
81 | {
82 | AnnotatedWithAtProtocolString {
83 | A {
84 | nodes {
85 | id
86 | }
87 | }
88 | }
89 | }
90 | `
91 | const response = await POST(path, { query })
92 | expect(response.data).not.toHaveProperty('errors')
93 | })
94 |
95 | test('service annotated with "@protocol: [\'graphql\']" is served at configured path', async () => {
96 | const query = gql`
97 | {
98 | AnnotatedWithAtProtocolStringList {
99 | A {
100 | nodes {
101 | id
102 | }
103 | }
104 | }
105 | }
106 | `
107 | const response = await POST(path, { query })
108 | expect(response.data).not.toHaveProperty('errors')
109 | })
110 |
111 | test('service annotated with "@protocol: [{kind: \'graphql\'}]" is served at configured path', async () => {
112 | const query = gql`
113 | {
114 | AnnotatedWithAtProtocolObjectList {
115 | A {
116 | nodes {
117 | id
118 | }
119 | }
120 | }
121 | }
122 | `
123 | const response = await POST(path, { query })
124 | expect(response.data).not.toHaveProperty('errors')
125 | })
126 |
127 | test('service annotated with "@protocol: { graphql }" is served at configured path', async () => {
128 | const query = gql`
129 | {
130 | AnnotatedWithAtProtocolObjectWithKey {
131 | A {
132 | nodes {
133 | id
134 | }
135 | }
136 | }
137 | }
138 | `
139 | const response = await POST(path, { query })
140 | expect(response.data).not.toHaveProperty('errors')
141 | })
142 |
143 | test('service annotated with "@protocol: { graphql: \'dummy\' }" is served at configured path', async () => {
144 | const query = gql`
145 | {
146 | AnnotatedWithAtProtocolObjectWithKeyAndValue {
147 | A {
148 | nodes {
149 | id
150 | }
151 | }
152 | }
153 | }
154 | `
155 | const response = await POST(path, { query })
156 | expect(response.data).not.toHaveProperty('errors')
157 | })
158 | })
159 | })
160 |
--------------------------------------------------------------------------------
/test/resources/bookshop/test/requests.http:
--------------------------------------------------------------------------------
1 | @server = http://localhost:4004
2 | @me = Authorization: Basic {{$processEnv USER}}:
3 |
4 |
5 | ### ------------------------------------------------------------------------
6 | # Get service info
7 | GET {{server}}/browse
8 | {{me}}
9 |
10 |
11 | ### ------------------------------------------------------------------------
12 | # Get $metadata document
13 | GET {{server}}/browse/$metadata
14 | {{me}}
15 |
16 |
17 | ### ------------------------------------------------------------------------
18 | # Browse Books as any user
19 | GET {{server}}/browse/ListOfBooks?
20 | # &$select=title,stock
21 | &$expand=genre
22 | # &sap-language=de
23 | {{me}}
24 |
25 |
26 | ### ------------------------------------------------------------------------
27 | # Fetch Authors as admin
28 | GET {{server}}/admin/Authors?
29 | # &$select=name,dateOfBirth,placeOfBirth
30 | # &$expand=books($select=title;$expand=currency)
31 | # &$filter=ID eq 101
32 | # &sap-language=de
33 | Authorization: Basic alice:
34 |
35 | ### ------------------------------------------------------------------------
36 | # Create Author
37 | POST {{server}}/admin/Authors
38 | Content-Type: application/json;IEEE754Compatible=true
39 | Authorization: Basic alice:
40 |
41 | {
42 | "ID": 112,
43 | "name": "Shakespeeeeere",
44 | "age": 22
45 | }
46 |
47 |
48 | ### ------------------------------------------------------------------------
49 | # Create book
50 | POST {{server}}/admin/Books
51 | Content-Type: application/json;IEEE754Compatible=true
52 | Authorization: Basic alice:
53 |
54 | {
55 | "ID": 2,
56 | "title": "Poems : Pocket Poets",
57 | "descr": "The Everyman's Library Pocket Poets hardcover series is popular for its compact size and reasonable price which does not compromise content. Poems: Bronte contains poems that demonstrate a sensibility elemental in its force with an imaginative discipline and flexibility of the highest order. Also included are an Editor's Note and an index of first lines.",
58 | "author": { "ID": 101 },
59 | "genre": { "ID": 12 },
60 | "stock": 5,
61 | "price": "12.05",
62 | "currency": { "code": "USD" }
63 | }
64 |
65 |
66 | ### ------------------------------------------------------------------------
67 | # Put image to books
68 | PUT {{server}}/admin/Books(2)/image
69 | Content-Type: image/png
70 | Authorization: Basic alice:
71 |
72 | data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAANwAAADcCAYAAAAbWs+BAAAGwElEQVR4Ae3cwZFbNxBFUY5rkrDTmKAUk5QT03Aa44U22KC7NHptw+DRikVAXf8fzC3u8Hj4R4AAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgZzAW26USQT+e4HPx+Mz+RRvj0e0kT+SD2cWAQK1gOBqH6sEogKCi3IaRqAWEFztY5VAVEBwUU7DCNQCgqt9rBKICgguymkYgVpAcLWPVQJRAcFFOQ0jUAsIrvaxSiAqILgop2EEagHB1T5WCUQFBBflNIxALSC42scqgaiA4KKchhGoBQRX+1glEBUQXJTTMAK1gOBqH6sEogKCi3IaRqAWeK+Xb1z9iN558fHxcSPS9p2ezx/ROz4e4TtIHt+3j/61hW9f+2+7/+UXbifjewIDAoIbQDWSwE5AcDsZ3xMYEBDcAKqRBHYCgtvJ+J7AgIDgBlCNJLATENxOxvcEBgQEN4BqJIGdgOB2Mr4nMCAguAFUIwnsBAS3k/E9gQEBwQ2gGklgJyC4nYzvCQwICG4A1UgCOwHB7WR8T2BAQHADqEYS2AkIbifjewIDAoIbQDWSwE5AcDsZ3xMYEEjfTzHwiK91B8npd6Q8n8/oGQ/ckRJ9vvQwv3BpUfMIFAKCK3AsEUgLCC4tah6BQkBwBY4lAmkBwaVFzSNQCAiuwLFEIC0guLSoeQQKAcEVOJYIpAUElxY1j0AhILgCxxKBtIDg0qLmESgEBFfgWCKQFhBcWtQ8AoWA4AocSwTSAoJLi5pHoBAQXIFjiUBaQHBpUfMIFAKCK3AsEUgLCC4tah6BQmDgTpPsHSTFs39p6fQ7Q770UsV/Ov19X+2OFL9wxR+rJQJpAcGlRc0jUAgIrsCxRCAtILi0qHkECgHBFTiWCKQFBJcWNY9AISC4AscSgbSA4NKi5hEoBARX4FgikBYQXFrUPAKFgOAKHEsE0gKCS4uaR6AQEFyBY4lAWkBwaVHzCBQCgitwLBFICwguLWoegUJAcAWOJQJpAcGlRc0jUAgIrsCxRCAt8J4eePq89B0ar3ZnyOnve/rfn1+400/I810lILirjtPLnC4guNNPyPNdJSC4q47Ty5wuILjTT8jzXSUguKuO08ucLiC400/I810lILirjtPLnC4guNNPyPNdJSC4q47Ty5wuILjTT8jzXSUguKuO08ucLiC400/I810lILirjtPLnC4guNNPyPNdJSC4q47Ty5wuILjTT8jzXSUguKuO08ucLiC400/I810l8JZ/m78+szP/zI47fJo7Q37vgJ7PHwN/07/3TOv/9gu3avhMYFhAcMPAxhNYBQS3avhMYFhAcMPAxhNYBQS3avhMYFhAcMPAxhNYBQS3avhMYFhAcMPAxhNYBQS3avhMYFhAcMPAxhNYBQS3avhMYFhAcMPAxhNYBQS3avhMYFhAcMPAxhNYBQS3avhMYFhAcMPAxhNYBQS3avhMYFhAcMPAxhNYBQS3avhMYFhg4P6H9J0maYHXuiMlrXf+vOfA33Turf3C5SxNItAKCK4lsoFATkBwOUuTCLQCgmuJbCCQExBcztIkAq2A4FoiGwjkBASXszSJQCsguJbIBgI5AcHlLE0i0AoIriWygUBOQHA5S5MItAKCa4lsIJATEFzO0iQCrYDgWiIbCOQEBJezNIlAKyC4lsgGAjkBweUsTSLQCgiuJbKBQE5AcDlLkwi0Akff//Dz6U+/I6U1/sUNr3bnytl3kPzi4bXb/cK1RDYQyAkILmdpEoFWQHAtkQ0EcgKCy1maRKAVEFxLZAOBnIDgcpYmEWgFBNcS2UAgJyC4nKVJBFoBwbVENhDICQguZ2kSgVZAcC2RDQRyAoLLWZpEoBUQXEtkA4GcgOByliYRaAUE1xLZQCAnILicpUkEWgHBtUQ2EMgJCC5naRKBVkBwLZENBHIC/4M7TXIv+3PS22d24qvdQfL3C/7N5P5i/MLlLE0i0AoIriWygUBOQHA5S5MItAKCa4lsIJATEFzO0iQCrYDgWiIbCOQEBJezNIlAKyC4lsgGAjkBweUsTSLQCgiuJbKBQE5AcDlLkwi0AoJriWwgkBMQXM7SJAKtgOBaIhsI5AQEl7M0iUArILiWyAYCOQHB5SxNItAKCK4lsoFATkBwOUuTCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIDAvyrwDySEJ2VQgUSoAAAAAElFTkSuQmCC
73 |
74 |
75 | ### ------------------------------------------------------------------------
76 | # Reading image from from the server directly
77 | GET {{server}}/browse/Books(2)/image
78 |
79 |
80 | ### ------------------------------------------------------------------------
81 | # Submit Order as authenticated user
82 | # (send that three times to get out-of-stock message)
83 | POST {{server}}/browse/submitOrder
84 | Content-Type: application/json
85 | {{me}}
86 |
87 | { "book":201, "quantity":5 }
88 |
89 |
90 | ### ------------------------------------------------------------------------
91 | # Browse Genres
92 | GET {{server}}/browse/Genres?
93 | # &$filter=parent_ID eq null&$select=name
94 | # &$expand=children($select=name)
95 | {{me}}
96 |
--------------------------------------------------------------------------------
/test/tests/queries/paging-offset.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - offset-based paging', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../../util')
5 |
6 | const { axios, POST } = cds.test(path.join(__dirname, '../../resources/bookshop-graphql'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 | axios.defaults.headers = {
10 | authorization: 'Basic YWxpY2U6'
11 | }
12 |
13 | // REVISIT: unskip for support of configurable schema flavors
14 | describe.skip('queries with paging arguments without connections', () => {
15 | test('query with top argument on field', async () => {
16 | const query = gql`
17 | {
18 | AdminServiceBasic {
19 | Authors(top: 2) {
20 | name
21 | }
22 | }
23 | }
24 | `
25 | const data = {
26 | AdminServiceBasic: {
27 | Authors: [{ name: 'Emily Brontë' }, { name: 'Charlotte Brontë' }]
28 | }
29 | }
30 | const response = await POST('/graphql', { query })
31 | expect(response.data).toEqual({ data })
32 | })
33 |
34 | test('query with top and skip arguments on field', async () => {
35 | const query = gql`
36 | {
37 | AdminServiceBasic {
38 | Authors(top: 2, skip: 2) {
39 | name
40 | }
41 | }
42 | }
43 | `
44 | const data = {
45 | AdminServiceBasic: {
46 | Authors: [{ name: 'Edgar Allen Poe' }, { name: 'Richard Carpenter' }]
47 | }
48 | }
49 | const response = await POST('/graphql', { query })
50 | expect(response.data).toEqual({ data })
51 | })
52 |
53 | test('query with top and skip arguments on nested fields', async () => {
54 | const query = gql`
55 | {
56 | AdminServiceBasic {
57 | Authors(top: 2, skip: 2) {
58 | name
59 | books(top: 1, skip: 1) {
60 | title
61 | }
62 | }
63 | }
64 | }
65 | `
66 | const data = {
67 | AdminService: {
68 | Authors: [
69 | {
70 | name: 'Edgar Allen Poe',
71 | books: [
72 | // Edgar Allen Poe has 2 books, but only 1 requested.
73 | {
74 | title: 'Eleonora'
75 | }
76 | ]
77 | },
78 | {
79 | name: 'Richard Carpenter',
80 | books: []
81 | }
82 | ]
83 | }
84 | }
85 |
86 | const response = await POST('/graphql', { query })
87 | expect(response.data).toEqual({ data })
88 | })
89 | })
90 |
91 | describe('queries with paging arguments with connections', () => {
92 | test('query with top argument on field', async () => {
93 | const query = gql`
94 | {
95 | AdminService {
96 | Authors(top: 2) {
97 | nodes {
98 | name
99 | }
100 | }
101 | }
102 | }
103 | `
104 | const data = {
105 | AdminService: {
106 | Authors: { nodes: [{ name: 'Emily Brontë' }, { name: 'Charlotte Brontë' }] }
107 | }
108 | }
109 | const response = await POST('/graphql', { query })
110 | expect(response.data).toEqual({ data })
111 | })
112 |
113 | test('query with top and skip arguments on field', async () => {
114 | const query = gql`
115 | {
116 | AdminService {
117 | Authors(top: 2, skip: 2) {
118 | nodes {
119 | name
120 | }
121 | }
122 | }
123 | }
124 | `
125 | const data = {
126 | AdminService: {
127 | Authors: { nodes: [{ name: 'Edgar Allen Poe' }, { name: 'Richard Carpenter' }] }
128 | }
129 | }
130 | const response = await POST('/graphql', { query })
131 | expect(response.data).toEqual({ data })
132 | })
133 |
134 | test('query with top and skip arguments on nested fields', async () => {
135 | const query = gql`
136 | {
137 | AdminService {
138 | Authors(top: 2, skip: 2) {
139 | nodes {
140 | name
141 | books(top: 1, skip: 1) {
142 | nodes {
143 | title
144 | }
145 | }
146 | }
147 | }
148 | }
149 | }
150 | `
151 | const data = {
152 | AdminService: {
153 | Authors: {
154 | nodes: [
155 | {
156 | name: 'Edgar Allen Poe',
157 | books: {
158 | // Edgar Allen Poe has 2 books, but only 1 requested.
159 | nodes: [
160 | {
161 | title: 'Eleonora'
162 | }
163 | ]
164 | }
165 | },
166 | {
167 | name: 'Richard Carpenter',
168 | books: {
169 | nodes: []
170 | }
171 | }
172 | ]
173 | }
174 | }
175 | }
176 |
177 | const response = await POST('/graphql', { query })
178 | expect(response.data).toEqual({ data })
179 | })
180 | })
181 | })
182 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, caste, color, religion, or sexual
10 | identity and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the overall
26 | community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or advances of
31 | any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email address,
35 | without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | cap@sap.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/test/tests/concurrency.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - resolver concurrency', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../util')
5 |
6 | const { axios, POST } = cds.test(path.join(__dirname, '../resources/concurrency'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 |
10 | describe('execution order of query and mutation resolvers', () => {
11 | let _log = []
12 |
13 | beforeEach(() => {
14 | console.log = s => _log.push(s) // eslint-disable-line no-console
15 | })
16 |
17 | afterEach(() => {
18 | _log = []
19 | })
20 |
21 | test('query resolvers should be executed in parallel', async () => {
22 | const query = gql`
23 | query {
24 | ConcurrencyService {
25 | A {
26 | nodes {
27 | id
28 | }
29 | }
30 | B {
31 | nodes {
32 | id
33 | }
34 | }
35 | C {
36 | nodes {
37 | id
38 | }
39 | }
40 | }
41 | }
42 | `
43 | await POST('/graphql', { query })
44 | expect(_log[0]).toEqual('BEGIN READ A')
45 | expect(_log[1]).toEqual('BEGIN READ B')
46 | expect(_log[2]).toEqual('BEGIN READ C')
47 | expect(_log[3]).toEqual('END READ B')
48 | expect(_log[4]).toEqual('END READ C')
49 | expect(_log[5]).toEqual('END READ A')
50 | })
51 |
52 | test('mutation resolvers should be executed serially', async () => {
53 | const query = gql`
54 | mutation {
55 | ConcurrencyService {
56 | A {
57 | create(input: {}) {
58 | id
59 | }
60 | }
61 | B {
62 | create(input: {}) {
63 | id
64 | }
65 | }
66 | C {
67 | create(input: {}) {
68 | id
69 | }
70 | }
71 | }
72 | }
73 | `
74 | await POST('/graphql', { query })
75 | expect(_log[0]).toEqual('BEGIN CREATE A')
76 | expect(_log[1]).toEqual('END CREATE A')
77 | expect(_log[2]).toEqual('BEGIN CREATE B')
78 | expect(_log[3]).toEqual('END CREATE B')
79 | expect(_log[4]).toEqual('BEGIN CREATE C')
80 | expect(_log[5]).toEqual('END CREATE C')
81 | })
82 | })
83 |
84 | describe('queries and mutations returning data and errors', () => {
85 | test('query resolvers return both data and an error', async () => {
86 | const query = gql`
87 | query {
88 | DataAndErrorsService {
89 | A {
90 | nodes {
91 | timestamp
92 | }
93 | }
94 | B {
95 | nodes {
96 | timestamp
97 | }
98 | }
99 | C {
100 | nodes {
101 | timestamp
102 | }
103 | }
104 | }
105 | }
106 | `
107 | const errors = [
108 | {
109 | message: 'My error on READ B',
110 | locations: [
111 | {
112 | line: 9,
113 | column: 13
114 | }
115 | ],
116 | path: ['DataAndErrorsService', 'B'],
117 | extensions: expect.any(Object)
118 | }
119 | ]
120 | const data = {
121 | DataAndErrorsService: {
122 | A: {
123 | nodes: [
124 | {
125 | timestamp: expect.any(String)
126 | }
127 | ]
128 | },
129 | B: null,
130 | C: {
131 | nodes: [
132 | {
133 | timestamp: expect.any(String)
134 | }
135 | ]
136 | }
137 | }
138 | }
139 | const response = await POST('/graphql', { query })
140 | expect(response.data).toEqual({ errors, data })
141 | // A sleeps 3000 ms, C sleeps 2000 ms
142 | // Since query resolvers are executed in parallel C should resolve before A
143 | expect(new Date(response.data.data.DataAndErrorsService.A.nodes[0].timestamp).getTime()).toBeGreaterThan(
144 | new Date(response.data.data.DataAndErrorsService.C.nodes[0].timestamp).getTime()
145 | )
146 | })
147 |
148 | test('mutation resolvers return both data and an error', async () => {
149 | const query = gql`
150 | mutation {
151 | DataAndErrorsService {
152 | A {
153 | create(input: {}) {
154 | timestamp
155 | }
156 | }
157 | B {
158 | create(input: {}) {
159 | timestamp
160 | }
161 | }
162 | C {
163 | create(input: {}) {
164 | timestamp
165 | }
166 | }
167 | }
168 | }
169 | `
170 | const errors = [
171 | {
172 | message: 'My error on CREATE B',
173 | locations: [
174 | {
175 | line: 10,
176 | column: 15
177 | }
178 | ],
179 | path: ['DataAndErrorsService', 'B', 'create'],
180 | extensions: expect.any(Object)
181 | }
182 | ]
183 | const data = {
184 | DataAndErrorsService: {
185 | A: {
186 | create: [
187 | {
188 | timestamp: expect.any(String)
189 | }
190 | ]
191 | },
192 | B: {
193 | create: null
194 | },
195 | C: {
196 | create: [
197 | {
198 | timestamp: expect.any(String)
199 | }
200 | ]
201 | }
202 | }
203 | }
204 | const response = await POST('/graphql', { query })
205 | expect(response.data).toEqual({ errors, data })
206 | // A sleeps 3000 ms, C sleeps 2000 ms
207 | // Since mutation resolvers are executed serially A should resolve before C
208 | expect(new Date(response.data.data.DataAndErrorsService.A.create[0].timestamp).getTime()).toBeLessThan(
209 | new Date(response.data.data.DataAndErrorsService.C.create[0].timestamp).getTime()
210 | )
211 | })
212 | })
213 | })
214 |
--------------------------------------------------------------------------------
/test/tests/queries/orderBy.test.js:
--------------------------------------------------------------------------------
1 | describe('graphql - orderBy', () => {
2 | const cds = require('@sap/cds')
3 | const path = require('path')
4 | const { gql } = require('../../util')
5 |
6 | const { axios, POST } = cds.test(path.join(__dirname, '../../resources/bookshop-graphql'))
7 | // Prevent axios from throwing errors for non 2xx status codes
8 | axios.defaults.validateStatus = false
9 | axios.defaults.headers = {
10 | authorization: 'Basic YWxpY2U6'
11 | }
12 |
13 | // REVISIT: unskip for support of configurable schema flavors
14 | describe.skip('queries with orderBy argument without connections', () => {
15 | test('query with single orderBy object on field', async () => {
16 | const query = gql`
17 | {
18 | AdminServiceBasic {
19 | Books(orderBy: { ID: desc }) {
20 | ID
21 | title
22 | }
23 | }
24 | }
25 | `
26 | const data = {
27 | AdminServiceBasic: {
28 | Books: [
29 | { ID: 271, title: 'Catweazle' },
30 | { ID: 252, title: 'Eleonora' },
31 | { ID: 251, title: 'The Raven' },
32 | { ID: 207, title: 'Jane Eyre' },
33 | { ID: 201, title: 'Wuthering Heights' }
34 | ]
35 | }
36 | }
37 | const response = await POST('/graphql', { query })
38 | expect(response.data).toEqual({ data })
39 | })
40 |
41 | test('query with list of orderBy object on field', async () => {
42 | // Use createdAt as first sort criteria to test second level sort criteria,
43 | // since they all have the same values due to being created at the same time
44 | const query = gql`
45 | {
46 | AdminServiceBasic {
47 | Books(orderBy: [{ createdAt: desc }, { ID: desc }]) {
48 | ID
49 | title
50 | }
51 | }
52 | }
53 | `
54 | const data = {
55 | AdminServiceBasic: {
56 | Books: [
57 | { ID: 271, title: 'Catweazle' },
58 | { ID: 252, title: 'Eleonora' },
59 | { ID: 251, title: 'The Raven' },
60 | { ID: 207, title: 'Jane Eyre' },
61 | { ID: 201, title: 'Wuthering Heights' }
62 | ]
63 | }
64 | }
65 | const response = await POST('/graphql', { query })
66 | expect(response.data).toEqual({ data })
67 | })
68 |
69 | test('query with orderBy objects on nested fields', async () => {
70 | const query = gql`
71 | {
72 | AdminServiceBasic {
73 | Authors(orderBy: { ID: desc }) {
74 | ID
75 | books(orderBy: { title: desc }) {
76 | title
77 | }
78 | }
79 | }
80 | }
81 | `
82 | const data = {
83 | AdminServiceBasic: {
84 | Authors: [
85 | { ID: 170, books: [{ title: 'Catweazle' }] },
86 | { ID: 150, books: [{ title: 'The Raven' }, { title: 'Eleonora' }] },
87 | { ID: 107, books: [{ title: 'Jane Eyre' }] },
88 | { ID: 101, books: [{ title: 'Wuthering Heights' }] }
89 | ]
90 | }
91 | }
92 | const response = await POST('/graphql', { query })
93 | expect(response.data).toEqual({ data })
94 | })
95 | })
96 |
97 | describe('queries with orderBy argument with connections', () => {
98 | test('query with single orderBy object on field', async () => {
99 | const query = gql`
100 | {
101 | AdminService {
102 | Books(orderBy: { ID: desc }) {
103 | nodes {
104 | ID
105 | title
106 | }
107 | }
108 | }
109 | }
110 | `
111 | const data = {
112 | AdminService: {
113 | Books: {
114 | nodes: [
115 | { ID: 271, title: 'Catweazle' },
116 | { ID: 252, title: 'Eleonora' },
117 | { ID: 251, title: 'The Raven' },
118 | { ID: 207, title: 'Jane Eyre' },
119 | { ID: 201, title: 'Wuthering Heights' }
120 | ]
121 | }
122 | }
123 | }
124 | const response = await POST('/graphql', { query })
125 | expect(response.data).toEqual({ data })
126 | })
127 |
128 | test('query with list of orderBy object on field', async () => {
129 | // Use createdAt as first sort criteria to test second level sort criteria,
130 | // since they all have the same values due to being created at the same time
131 | const query = gql`
132 | {
133 | AdminService {
134 | Books(orderBy: [{ createdAt: desc }, { ID: desc }]) {
135 | nodes {
136 | ID
137 | title
138 | }
139 | }
140 | }
141 | }
142 | `
143 | const data = {
144 | AdminService: {
145 | Books: {
146 | nodes: [
147 | { ID: 271, title: 'Catweazle' },
148 | { ID: 252, title: 'Eleonora' },
149 | { ID: 251, title: 'The Raven' },
150 | { ID: 207, title: 'Jane Eyre' },
151 | { ID: 201, title: 'Wuthering Heights' }
152 | ]
153 | }
154 | }
155 | }
156 | const response = await POST('/graphql', { query })
157 | expect(response.data).toEqual({ data })
158 | })
159 |
160 | test('query with orderBy objects on nested fields', async () => {
161 | const query = gql`
162 | {
163 | AdminService {
164 | Authors(orderBy: { ID: desc }) {
165 | nodes {
166 | ID
167 | books(orderBy: { title: desc }) {
168 | nodes {
169 | title
170 | }
171 | }
172 | }
173 | }
174 | }
175 | }
176 | `
177 | const data = {
178 | AdminService: {
179 | Authors: {
180 | nodes: [
181 | { ID: 170, books: { nodes: [{ title: 'Catweazle' }] } },
182 | { ID: 150, books: { nodes: [{ title: 'The Raven' }, { title: 'Eleonora' }] } },
183 | { ID: 107, books: { nodes: [{ title: 'Jane Eyre' }] } },
184 | { ID: 101, books: { nodes: [{ title: 'Wuthering Heights' }] } }
185 | ]
186 | }
187 | }
188 | }
189 | const response = await POST('/graphql', { query })
190 | expect(response.data).toEqual({ data })
191 | })
192 | })
193 | })
194 |
--------------------------------------------------------------------------------