├── .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 |
22 |
23 |
Tenant: {{ user.tenant }}
24 |
User: {{ user.id }}
25 |
Locale: {{ user.locale }}
26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 |

Capire Books

34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 52 | 53 | 54 |
Book Author Genre Rating Price
{{ book.title }}{{ book.author }}{{ book.genre.name }} 50 | {{ ('★'.repeat(Math.round(book.rating))+'☆☆☆☆☆').slice(0,5) }} ({{ book.numberOfReviews }}) 51 | {{ book.currency && book.currency.symbol }} {{ book.price }}
55 | 56 |
57 | 58 | 63 |
64 | 65 | 66 |
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 | [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/graphql)](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 | --------------------------------------------------------------------------------