diff --git a/templates/ords-remix-jwt-sample/.env.example b/templates/ords-remix-jwt-sample/.env.example index 3c55016..7c6b694 100644 --- a/templates/ords-remix-jwt-sample/.env.example +++ b/templates/ords-remix-jwt-sample/.env.example @@ -2,27 +2,41 @@ # All rights reserved # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ -# We refer to some variables as Autonomous Database specific -# but you can use whichever ORDS URL you want/have as well as the user, -# as long as this user is capable of creating and REST Enabling other schemas. -ADB_ORDS_URL=https://example.com:8080/ords/ -ADB_ADMIN_USER=username -ADB_ADMIN_PASSWORD= +# ORDS base URL for your Base Database or ORDS instance. +# Replace , , and with your actual values. +# Keep the trailing slash. +BD_ORDS_URL=http://:/ords/ + +# Oracle Database connect string used by migrate/seed/drop scripts. +# Examples: +# host:1521/service_name +# myadb_high (from tnsnames.ora when TNS_ADMIN is configured) +BD_CONNECT_STRING=:/ + +# Oracle Instant Client directory for Thick mode in node-oracledb. +ORACLE_CLIENT_LIB_DIR=/instantclient_23_26 + +# OCI IAM JWT credentials, used by ORDS to validate request to protected endpoints. +JWT_ISSUER=https://identity.oraclecloud.com/ +JWT_VERIFICATION_KEY=https://:443/admin/v1/SigningCert/jwk +JWT_AUDIENCE=ords/sample-app/ + +BD_ADMIN_USER=BD_admin +BD_ADMIN_PASSWORD= # The name of the schema that will be created to host all of the # ORDS Concert App database objects. SCHEMA_NAME=ORDS_CONCERT_APP SCHEMA_PASSWORD= -# Your Auth0 tenant JWT credentials, used by ORDS to validate request to protected endpoints. -JWT_ISSUER=https://my-domain.auth0.com/ -JWT_VERIFICATION_KEY=https://my-domain.auth0.com/oauth/token/.well-known/jwks.json -JWT_AUDIENCE=https://concert.sample.app - -# Auth0 Authentication app configuration parameters specific of the sample app. -AUTH0_RETURN_TO_URL=http://localhost:3000 -AUTH0_CALLBACK_URL=http://localhost:3000/callback -AUTH0_CLIENT_ID=auth0_client_id -AUTH0_CLIENT_SECRET=auth0_client_secret -AUTH0_DOMAIN=my-domain.auth0.com -AUTH0_LOGOUT_URL=https://my-domain.auth0.com/v2/logout +# OCI IAM OIDC application configuration parameters specific to the sample app. +OIDC_RETURN_TO_URL=http://localhost:3000 +OIDC_REDIRECT_URI=http://localhost:3000/callback +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_AUTHORIZATION_ENDPOINT=https://:443/oauth2/v1/authorize +OIDC_TOKEN_ENDPOINT=https://:443/oauth2/v1/token +OIDC_USERINFO_ENDPOINT=https://:443/oauth2/v1/userinfo +OIDC_LOGOUT_ENDPOINT=https://:443/oauth2/v1/userlogout +OIDC_AUDIENCE=ords/sample-app/ +OIDC_SCOPES=openid,email,profile,ords/sample-app/concert_app_authuser,ords/sample-app/concert_app_admin diff --git a/templates/ords-remix-jwt-sample/.gitignore b/templates/ords-remix-jwt-sample/.gitignore new file mode 100644 index 0000000..7e430a1 --- /dev/null +++ b/templates/ords-remix-jwt-sample/.gitignore @@ -0,0 +1,20 @@ +# MacOS +.DS_Store +.DS_Store? + +# Visual Studio Code +.vscode/ + +# Node and NPM installation folder +/node + +# Node Modules +node_modules/ + +# Remix +/.cache +/build +/public/build + +# Enviorement variables +.env diff --git a/templates/ords-remix-jwt-sample/app/components/admin/EventForm.tsx b/templates/ords-remix-jwt-sample/app/components/admin/EventForm.tsx index c1986fd..72ed396 100644 --- a/templates/ords-remix-jwt-sample/app/components/admin/EventForm.tsx +++ b/templates/ords-remix-jwt-sample/app/components/admin/EventForm.tsx @@ -10,7 +10,6 @@ import Artist from '../../models/Artist'; import EventStatus from '../../models/EventStatus'; import ORDSResponse from '../../models/ORDSResponse'; import Venue from '../../models/Venue'; -import { BASE } from '../../routes/constants/index.server'; import TooltipComponent from '../tooltips/TooltipComponent'; import featureDescriptions from '../../utils/ORDSFeaturesDescription'; @@ -34,15 +33,15 @@ function EventForm(props: EventFormProps) { const [eventStatus, setEventStatus] = React.useState(status.items[0].event_status_id); const handleArtistSelect = (event: React.FormEvent) => { const target = event.target as HTMLInputElement; - setArtist(parseInt(target.value, BASE)); + setArtist(parseInt(target.value, 10)); }; const handleVenueSelect = (event: React.FormEvent) => { const target = event.target as HTMLInputElement; - setVenue(parseInt(target.value, BASE)); + setVenue(parseInt(target.value, 10)); }; const handleStatusSelect = (event: React.FormEvent) => { const target = event.target as HTMLInputElement; - setEventStatus(parseInt(target.value, BASE)); + setEventStatus(parseInt(target.value, 10)); }; return (
diff --git a/templates/ords-remix-jwt-sample/app/components/admin/VenuesForm.tsx b/templates/ords-remix-jwt-sample/app/components/admin/VenuesForm.tsx index 015e999..e5c22d1 100644 --- a/templates/ords-remix-jwt-sample/app/components/admin/VenuesForm.tsx +++ b/templates/ords-remix-jwt-sample/app/components/admin/VenuesForm.tsx @@ -8,7 +8,6 @@ import { Form } from '@remix-run/react'; import React from 'react'; import City from '../../models/City'; import ORDSResponse from '../../models/ORDSResponse'; -import { BASE } from '../../routes/constants/index.server'; interface VenuesFormProps { cities: ORDSResponse @@ -25,7 +24,7 @@ function VenuesForm(props: VenuesFormProps) { const handleCitySelect = (event: React.FormEvent) => { const target = event.target as HTMLInputElement; - setCity(parseInt(target.value, BASE)); + setCity(parseInt(target.value, 10)); }; return ( diff --git a/templates/ords-remix-jwt-sample/app/components/artists/ArtistHome.tsx b/templates/ords-remix-jwt-sample/app/components/artists/ArtistHome.tsx index b699b00..2ce9878 100644 --- a/templates/ords-remix-jwt-sample/app/components/artists/ArtistHome.tsx +++ b/templates/ords-remix-jwt-sample/app/components/artists/ArtistHome.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; import { Divider } from '@mui/material'; -import { Auth0Profile } from 'remix-auth-auth0'; import HeroBanner from './HeroBanner'; import Countdown from '../homepage/Countdown'; import Timeline from '../homepage/Timeline'; @@ -15,12 +14,13 @@ import DiscoverArtists from './DiscoverArtists'; import Artist from '../../models/Artist'; import ORDSResponse from '../../models/ORDSResponse'; import ORDSConcert from '../../models/ORDSConcert'; +import OIDCProfile from '../../models/OIDCProfile'; interface ArtistHomeProps { artists: ORDSResponse< Artist>; events: ORDSResponse< ORDSConcert >; similarArtists: ORDSResponse< Artist >; - user: Auth0Profile | null; + user: OIDCProfile | null; likedArtist: boolean; } /** diff --git a/templates/ords-remix-jwt-sample/app/components/artists/HeroBanner.tsx b/templates/ords-remix-jwt-sample/app/components/artists/HeroBanner.tsx index 273f4fa..a064892 100644 --- a/templates/ords-remix-jwt-sample/app/components/artists/HeroBanner.tsx +++ b/templates/ords-remix-jwt-sample/app/components/artists/HeroBanner.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { Box, CircularProgress, Modal, Chip, } from '@mui/material'; -import { Auth0Profile } from 'remix-auth-auth0'; import { Form, useNavigation, useSearchParams } from '@remix-run/react'; import Artist from '../../models/Artist'; import { modalStyle } from '../../CommonStyles'; @@ -20,10 +19,11 @@ import artistImages from '../utils/artistImages'; import artistBackgrounds from '../utils/artistBackgrounds'; import artistBioColor from '../utils/artistBioTextColor'; import artistTitleColor from '../utils/artistTitleColor'; +import OIDCProfile from '../../models/OIDCProfile'; interface HeroBannerProps { artist: Artist; - user: Auth0Profile | null; + user: OIDCProfile | null; userLikedArtist: boolean; } @@ -160,7 +160,7 @@ function HeroBanner(props: HeroBannerProps) { 100 followers

-
+ diff --git a/templates/ords-remix-jwt-sample/app/components/concerts/ConcertBanner.tsx b/templates/ords-remix-jwt-sample/app/components/concerts/ConcertBanner.tsx index e297f05..d2cfab7 100644 --- a/templates/ords-remix-jwt-sample/app/components/concerts/ConcertBanner.tsx +++ b/templates/ords-remix-jwt-sample/app/components/concerts/ConcertBanner.tsx @@ -219,7 +219,7 @@ function ConcertBanner(props : ConcertBannerProps) { people going

- + + +
); diff --git a/templates/ords-remix-jwt-sample/app/utils/auth.server.ts b/templates/ords-remix-jwt-sample/app/utils/auth.server.ts index 19353f6..c337807 100644 --- a/templates/ords-remix-jwt-sample/app/utils/auth.server.ts +++ b/templates/ords-remix-jwt-sample/app/utils/auth.server.ts @@ -4,21 +4,29 @@ ** All rights reserved ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ */ -import { createCookieSessionStorage } from '@remix-run/node'; +import { createFileSessionStorage } from '@remix-run/node'; import { Authenticator } from 'remix-auth'; -import type { Auth0Profile } from 'remix-auth-auth0'; -import { Auth0Strategy } from 'remix-auth-auth0'; +import { OAuth2Strategy } from 'remix-auth-oauth2'; import { - AUTH0_CALLBACK_URL, - AUTH0_CLIENT_ID, - AUTH0_CLIENT_SECRET, - AUTH0_DOMAIN, COOKIE_MAX_AGE, - AUTH0_AUDIENCE, + OIDC_AUDIENCE, + OIDC_AUTHORIZATION_ENDPOINT, + OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET, + OIDC_REDIRECT_URI, + OIDC_SCOPES, + OIDC_TOKEN_ENDPOINT, + OIDC_USERINFO_ENDPOINT, } from '~/routes/constants/index.server'; +import type OIDCProfile from '~/models/OIDCProfile'; -const sessionStorage = createCookieSessionStorage({ +const DEFAULT_PROFILE_PICTURE = '/favicon.ico'; + +const readClaim = (claim: unknown): string => (typeof claim === 'string' ? claim : ''); + +const sessionStorage = createFileSessionStorage({ + dir: '/tmp/remix-sessions', cookie: { name: '_remix_session', sameSite: 'lax', @@ -31,24 +39,71 @@ const sessionStorage = createCookieSessionStorage({ }); type UserProfile = { - profile: Auth0Profile; + profile: OIDCProfile; tokenType: string; accessToken: string }; export const auth = new Authenticator(sessionStorage); -const auth0Strategy = new Auth0Strategy( +class OIDCOAuth2Strategy extends OAuth2Strategy { + protected authorizationParams(params: URLSearchParams): URLSearchParams { + const configuredScopes = OIDC_SCOPES.split(',').map((scope) => scope.trim()).filter(Boolean).join(' '); + if (configuredScopes) { + params.set('scope', configuredScopes); + } + if (OIDC_AUDIENCE) { + params.set('audience', OIDC_AUDIENCE); + } + return params; + } + + protected async userProfile(accessToken: string): Promise { + const userInfoResponse = await fetch(OIDC_USERINFO_ENDPOINT, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!userInfoResponse.ok) { + throw new Error(`OIDC userinfo request failed with status ${userInfoResponse.status}`); + } + + const userInfo = (await userInfoResponse.json()) as Record; + const email = readClaim(userInfo.email); + const picture = readClaim(userInfo.picture) || DEFAULT_PROFILE_PICTURE; + const displayName = readClaim(userInfo.name) + || readClaim(userInfo.preferred_username) + || email + || 'User'; + const nickname = readClaim(userInfo.nickname) || readClaim(userInfo.preferred_username); + const id = readClaim(userInfo.sub) || readClaim(userInfo.user_id) || email || displayName; + + return { + provider: 'oidc', + id, + displayName, + emails: email ? [{ value: email }] : [], + photos: [{ value: picture }], + _json: { + ...userInfo, + nickname, + }, + }; + } +} + +const oidcStrategy = new OIDCOAuth2Strategy( { - callbackURL: AUTH0_CALLBACK_URL, - clientID: AUTH0_CLIENT_ID, - clientSecret: AUTH0_CLIENT_SECRET, - domain: AUTH0_DOMAIN, - audience: AUTH0_AUDIENCE, - scope: ['openid', 'email', 'profile', 'concert_app_authuser', 'concert_app_admin'], + clientID: OIDC_CLIENT_ID, + clientSecret: OIDC_CLIENT_SECRET, + authorizationURL: OIDC_AUTHORIZATION_ENDPOINT, + tokenURL: OIDC_TOKEN_ENDPOINT, + callbackURL: OIDC_REDIRECT_URI, }, async ({ - accessToken, profile, + accessToken, + profile, }) => { const tokenType = 'Bearer'; return { @@ -59,7 +114,7 @@ const auth0Strategy = new Auth0Strategy( }, ); -auth.use(auth0Strategy); +auth.use(oidcStrategy, 'oidc'); export const { getSession, commitSession, destroySession, diff --git a/templates/ords-remix-jwt-sample/ords/README.MD b/templates/ords-remix-jwt-sample/ords/README.MD index 052f189..b68dcd6 100644 --- a/templates/ords-remix-jwt-sample/ords/README.MD +++ b/templates/ords-remix-jwt-sample/ords/README.MD @@ -2,195 +2,70 @@ ![ORDS Logo](../images/ORDS.png) -## Table of Contents +## Overview -- [Introduction](#introduction) -- [Migrate Script](#migrate-script) -- [Seed Script](#seed-script) -- [Drop Script](#drop-script) +This folder contains scripts used to manage the ORDS Concert App backend lifecycle: +- `migrate` to create schema + database objects (no ORDS package calls) +- `seed` to insert starter CSV data with direct SQL inserts +- `drop` to remove the schema and related objects -## Introduction +## Script Summary -ORDS Management Scripts provides a comprehensive set of scripts designed to manage and streamline various Database RESTful operations, including schema creation, data population, and schema removal. +| Script | Command | Purpose | +|---|---|---| +| Migrate | `npm run migrate` | Creates schema user + tables/views/sequences/triggers | +| Seed | `npm run seed` | Loads starter CSV data directly into schema tables | +| Drop | `npm run drop` | Drops sample schema and schema objects | -The main goal of this collection of scripts is to automate and facilitate the creation of an Oracle Database Schema that follows best practices with all the objects and REST Services that the Concert App needs as well as provide a guide on how to configure Oracle RESTful services. +## Environment Variables -This documentation focuses on three primary scripts: +The `.env.example` file is focused on app runtime + OIDC settings. If you run ORDS management scripts, include these additional values in `.env`. -- **migrate** 🚀: Creates the database objects such as tables, triggers, and views and uses those objects to define a collection of REST Services that the Sample App consumes. -- **seed** 🌱: Populates the created objects with initial data using the autoREST batch load functionality. -- **drop** 🗑️: Deletes the schema and all of the associated database objects. - -## Migrate Script - -The `migrate` script is designed to set up the database schema and REST enable it, followed by the creation of the necessary database objects and ORDS modules definitions. This script performs the following tasks: - -1. **Create a Schema and RESTEnable it**: - - Creates a new database schema. - - REST enables the schema, allowing it to support RESTful Web Services. - -2. **Database Objects Creation**: - - Uses the credentials of the recently created schema to create the essential database objects such as tables, indexes and triggers that the Concert App needs to work. - -3. **ORDS Modules Definition**: - - Defines three distinct ORDS modules: `enduser`, `authUser`, and `adminUser`. - - Each module is associated with specific templates and handlers tailored for different types of users. - - Protects each module with a unique privilege to ensure appropriate access control. - -4. **AutoREST Enabling**: - - AutoREST enables certain tables and views to facilitate their use by the Concert App and the seeding script. Each table is also protected by a privilege to ensure appropriate access control. - -You can learn more about the Migrate Script in the [Migrate Scripts Documentation](migrateScripts/README.md) as well as the [Modules Definitions Documentation](modules/README.md). - -### Configuration - -The script requires that the following constants are defined in a `.env` file at the same level of the `package.json` file of the ORDS Concert App project: +### Required for `npm run migrate` ```bash -ADB_ORDS_URL=example.com:8080/ords/ -ADB_ADMIN_USERNAME=username -ADB_ADMIN_PASSWORD=password - -SCHEMA_NAME=CONCERT_SAMPLE_APP -SCHEMA_PASSWORD= - -JWT_ISSUER=https://my-domain.auth0.com/ -JWT_VERIFICATION_KEY=https://my-domain.auth0.com/oauth/token/.well-known/jwks.json - -##This has to coincide with the audience you configured in your Auth0 app. -JWT_AUDIENCE=https://concert.sample.app +BD_CONNECT_STRING=:/ +ORACLE_CLIENT_LIB_DIR=//instantclient_23_26 +BD_ADMIN_USER=BD_admin +BD_ADMIN_PASSWORD= +SCHEMA_NAME=ORDS_CONCERT_APP +SCHEMA_PASSWORD= ``` -For more info about these constants refer to the [Configuration Section of the Sample App Installation Guide](../README.md#setup-your-environment). - -### Usage - -Run the following command from your terminal: - -```bash -npm run migrate -``` -Once the command is run you will asked if you want to use the credentials provided in the `.env` (type `n`) file or if you want to provide each one of those via the command line (type `y`). +### Required for `npm run seed` ```bash -> ords-remix-jwt-sample@1.0.0 migrate -> node ./ords/migrate.js - -Do you want to set your config with the CLI? (y/n): +BD_CONNECT_STRING=:/ +ORACLE_CLIENT_LIB_DIR=//instantclient_23_26 +SCHEMA_NAME=ORDS_CONCERT_APP +SCHEMA_PASSWORD= ``` - 🚨 **Note** that defining the configuration options in a `.env` file is the recommended option and should be used instead of relying on the manual option. The use of the CLI interface is mean to be a one time usage and assumes that you do not intend to run the seeding script once the schema objects are created. - -Once the script has finished it will display all the statements that were executed and if there was an error in any of those statements. - -## Seed Script - -The `seed` script is designed to populate the database with initial data, ensuring that the application has the necessary data to function correctly. It leverages the autoREST batch load functionality to efficiently insert data into the AutoREST enabled tables created by the `migrate` script. This script performs the following tasks: - -1. Connects to the database using the recently created Schema Credentials. -2. Utilizes the AutoREST batch load functionality to insert initial data into the recently created tables. - -You can learn more about the Seed Script in the [Seeding Scripts Documentation](seedScripts/README.md). -### Configuration - -The script requires that the following constants are defined in a `.env` file at the same level of the `package.json` file of the ORDS Concert App project: - -```python -ADB_ORDS_URL=example.com:8080/ords/ -SCHEMA_NAME=CONCERT_SAMPLE_APP -SCHEMA_PASSWORD=SuperS3curePsswd -``` - -For more info about these constants refer to the [Configuration Section of the Sample App Installation Guide](../README.md#getting-started). - -### Usage - -Run the following command from your terminal: +### Required for `npm run drop` ```bash -npm run seed +BD_CONNECT_STRING=:/ +ORACLE_CLIENT_LIB_DIR=//instantclient_23_26 +BD_ADMIN_USER=BD_admin +BD_ADMIN_PASSWORD= +SCHEMA_NAME=ORDS_CONCERT_APP ``` -Once the script has finished, it will display a resume of all of the rows that were inserted during execution and it will also report if there was during any of the batchload operations. - -```bash -> ords-remix-jwt-sample@1.0.0 seed -> node ./ords/seed.js - -#INFO Number of rows processed: 50 -#INFO Number of rows in error: 0 -#INFO No rows committed -SUCCESS: Processed without errors - -#INFO Number of rows processed: 100 -#INFO Number of rows in error: 0 -#INFO No rows committed -SUCCESS: Processed without errors - -#INFO Number of rows processed: 100 -#INFO Number of rows in error: 0 -#INFO No rows committed -SUCCESS: Processed without errors - -#INFO Number of rows processed: 9 -#INFO Number of rows in error: 0 -#INFO Last row processed in final committed batch: 9 -SUCCESS: Processed without errors - -#INFO Number of rows processed: 256 -#INFO Number of rows in error: 0 -#INFO Last row processed in final committed batch: 256 -SUCCESS: Processed without errors -``` - -## Drop Script - -The `drop` script is designed to cleanly and thoroughly remove all traces of the database schema and its associated objects. This script is useful for completely removing the Concert App database components. It performs the following tasks: -1. **Dropping REST Objects**: - - Drops all REST objects associated with the schema, including modules, templates, handlers and security clients. +## OCI Database API Gateway / OCS -2. **REST Disabling the Schema**: - - REST disables the schema, removing its capability to support RESTful web services. +Use the ords-sample-app skill to generate API spec and auto API spec. -3. **Disconnecting Active Sessions**: - - Disconnects any active user sessions to ensure that no operations are being performed on the schema during the deletion process. +Review generated payloads before executing in shared environments. -4. **Deleting Schema Database Objects**: - - Deletes all database objects within the schema, such as tables, indexes, triggers, and views. +## Additional Documentation -You can learn more about the Drop Script in the [Utils Scripts Documentation](utils/README.md). +- [Security and endpoint protection](./security/README.MD) +- [Module definitions and endpoint modeling](./modules/README.md) +- [Migration scripts details](./migrateScripts/README.md) +- [Seed scripts details](./seedScripts/README.md) +- [Drop script details](./utils/README.md) -### Configuration +## Disclaimer -The script requires that the following constants are defined in a `.env` file at the same level of the `package.json` file of the ORDS Concert App project: - -```python -ADB_ORDS_URL=example.com:8080/ords/ -ADB_ADMIN_USERNAME=username -ADB_ADMIN_PASSWORD=password -SCHEMA_NAME=CONCERT_SAMPLE_APP -SCHEMA_PASSWORD=SuperS3curePsswd -``` - -For more info about these constants refer to the [Configuration Section of the Sample App Installation Guide](../README.md#getting-started). - -### Usage - -Run the following command from your terminal: - -```bash -npm run drop -``` - -Once the script has finished it will display a resume of all REST SQL calls that were executed. - -```bash -> ords-remix-jwt-sample@1.0.0 drop -> node ./ords/drop.js - -id : 4 -statement : DROP USER CONCERT_SAMPLE_APP CASCADE -response: -User CONCERT_SAMPLE_APP dropped. -``` \ No newline at end of file +All names, characters, organizations, places, events and portrayals are entirely fictional for the sole purpose of this application's demonstration. Any resemblance to actual persons (living or dead) or actual entities or events is purely coincidental. diff --git a/templates/ords-remix-jwt-sample/ords/RESTfulServices/RESTSchema.js b/templates/ords-remix-jwt-sample/ords/RESTfulServices/RESTSchema.js index a98eee3..8121cfa 100644 --- a/templates/ords-remix-jwt-sample/ords/RESTfulServices/RESTSchema.js +++ b/templates/ords-remix-jwt-sample/ords/RESTfulServices/RESTSchema.js @@ -4,60 +4,90 @@ ** All rights reserved ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ */ -import { - postRequest, - printResponse, -} from '../utils/ORDSRequests.js'; +import { executeWithLogging } from '../utils/sqlExecutionLogger.js'; + +const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_$#]*$/; + +/** + * Validates and normalizes an Oracle identifier. + * @param {string} identifier the raw identifier. + * @param {string} fieldName the field name for error messages. + * @returns {string} the validated uppercase identifier. + */ +function normalizeIdentifier(identifier, fieldName) { + if (!identifier || !IDENTIFIER_PATTERN.test(identifier)) { + throw new Error(`Invalid ${fieldName}. Use only Oracle identifier-safe characters.`); + } + return identifier.toUpperCase(); +} + +/** + * Escapes a password so it can be used as a quoted Oracle password literal. + * @param {string} password the password to escape. + * @returns {string} quoted password. + */ +function getQuotedPassword(password) { + return `"${password.replaceAll('"', '""')}"`; +} /** - * Creates a Schema and REST-Enable it. - * Uses the ADMIN credentials. - * @param {string} schemaName the name of the schema to create. - * @param {string} schemaPassword the password of the schema to create. - * @param {string} endpoint the ADB-S admin endpoint. - * @param {string} basicAuth the authentication string for the admin user. + * Checks if an Oracle user already exists. + * @param {import('oracledb').Connection} connection the admin connection. + * @param {string} schemaName the schema name. + * @returns {Promise} true when the user exists. */ -async function createSchema(schemaName, schemaPassword, endpoint, basicAuth) { - const createSchemaStatement = ` - CREATE USER ${schemaName} IDENTIFIED BY ${schemaPassword} - DEFAULT TABLESPACE DATA - QUOTA UNLIMITED ON DATA; +async function userExists(connection, schemaName) { + const result = await executeWithLogging( + connection, + 'SELECT COUNT(*) FROM ALL_USERS WHERE USERNAME = :schemaName', + { schemaName }, + ); + return (result.rows?.[0]?.[0] || 0) > 0; +} + +/** + * Creates the schema user and grants required object privileges. + * This workflow intentionally avoids ORDS package calls in the target database. + * @param {import('oracledb').Connection} connection the admin connection. + * @param {string} schemaName the schema name. + * @param {string} schemaPassword the schema password. + */ +async function createSchema(connection, schemaName, schemaPassword) { + const normalizedSchemaName = normalizeIdentifier(schemaName, 'SCHEMA_NAME'); + if (!schemaPassword || schemaPassword.trim() === '') { + throw new Error('Missing required setting: SCHEMA_PASSWORD'); + } + + const quotedPassword = getQuotedPassword(schemaPassword); + if (await userExists(connection, normalizedSchemaName)) { + await executeWithLogging( + connection, + `ALTER USER ${normalizedSchemaName} IDENTIFIED BY ${quotedPassword}`, + ); + } else { + await executeWithLogging(connection, ` + CREATE USER ${normalizedSchemaName} + IDENTIFIED BY ${quotedPassword} + DEFAULT TABLESPACE DATA + QUOTA UNLIMITED ON DATA + `); + } + await executeWithLogging( + connection, + `ALTER USER ${normalizedSchemaName} QUOTA UNLIMITED ON DATA`, + ); + await executeWithLogging(connection, ` GRANT CREATE PROCEDURE, - CREATE SEQUENCE, - CREATE SESSION, - CREATE SYNONYM, - CREATE TABLE, - CREATE TRIGGER, - CREATE TYPE, - CREATE VIEW - TO ${schemaName}; - - DECLARE - L_PRIV_ROLES owa.vc_arr; - L_PRIV_PATTERNS owa.vc_arr; - L_PRIV_MODULES owa.vc_arr; - - BEGIN - L_PRIV_ROLES( 1 ) := 'oracle.dbtools.autorest.any.schema'; - L_PRIV_ROLES( 2 ) := 'SQL Developer'; - - ORDS.ENABLE_SCHEMA( - P_ENABLED => TRUE, - P_SCHEMA => '${schemaName}', - P_URL_MAPPING_TYPE => 'BASE_PATH', - P_URL_MAPPING_PATTERN => '${schemaName.toLowerCase()}', - P_AUTO_REST_AUTH => FALSE - ); - - - - COMMIT; - END; - / - `; - const createSchemaResponse = await postRequest(endpoint, createSchemaStatement, basicAuth); - printResponse(createSchemaResponse); + CREATE SEQUENCE, + CREATE SESSION, + CREATE SYNONYM, + CREATE TABLE, + CREATE TRIGGER, + CREATE TYPE, + CREATE VIEW + TO ${normalizedSchemaName} + `); } export default createSchema; diff --git a/templates/ords-remix-jwt-sample/ords/drop.js b/templates/ords-remix-jwt-sample/ords/drop.js index 3be955c..71ee4b1 100644 --- a/templates/ords-remix-jwt-sample/ords/drop.js +++ b/templates/ords-remix-jwt-sample/ords/drop.js @@ -7,22 +7,58 @@ import * as dotenv from 'dotenv'; import path from 'path'; import dropUser from './utils/dropUser.js'; +import { + closeConnection, + getConnection, +} from './utils/oracleConnection.js'; dotenv.config({ path: `${path.resolve()}/.env` }); const { - ADB_ORDS_URL, SCHEMA_NAME, - ADB_ADMIN_USER, - ADB_ADMIN_PASSWORD, + BD_CONNECT_STRING, + SCHEMA_NAME, + BD_ADMIN_USER, + BD_ADMIN_PASSWORD, } = process.env; -const ORDS_ADMIN_AUTH_CREDENTIALS = `${ADB_ADMIN_USER}:${ADB_ADMIN_PASSWORD}`; -const BASIC_ADMIN_AUTH = `Basic ${Buffer.from(ORDS_ADMIN_AUTH_CREDENTIALS).toString('base64')}`; -const ADB_ADMIN_ENDPOINT = `${ADB_ORDS_URL}admin/_/sql`; /** - * drop script, drops the schema and all of the objects associated to it. + * Returns a required environment variable and throws when missing. + * @param {string | undefined} value the variable value. + * @param {string} variableName the variable name. + * @returns {string} the non-empty variable value. + */ +function getRequiredEnvVar(value, variableName) { + if (!value || value.trim() === '') { + throw new Error(`Missing required environment variable: ${variableName}`); + } + return value; +} + +/** + * Drop script: drops the schema and all associated objects. */ async function drop() { - await dropUser(SCHEMA_NAME, ADB_ADMIN_ENDPOINT, BASIC_ADMIN_AUTH); + const connectString = getRequiredEnvVar(BD_CONNECT_STRING, 'BD_CONNECT_STRING'); + const schemaName = getRequiredEnvVar(SCHEMA_NAME, 'SCHEMA_NAME'); + const adminUser = getRequiredEnvVar(BD_ADMIN_USER, 'BD_ADMIN_USER'); + const adminPassword = getRequiredEnvVar(BD_ADMIN_PASSWORD, 'BD_ADMIN_PASSWORD'); + + let adminConnection; + try { + adminConnection = await getConnection({ + user: adminUser, + password: adminPassword, + connectString, + }); + await dropUser(adminConnection, schemaName); + // eslint-disable-next-line no-console + console.log(`Drop completed successfully for schema ${schemaName}.`); + } finally { + await closeConnection(adminConnection); + } } -drop(); +drop().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; +}); diff --git a/templates/ords-remix-jwt-sample/ords/migrate.js b/templates/ords-remix-jwt-sample/ords/migrate.js index 80cfb8d..3c1f354 100644 --- a/templates/ords-remix-jwt-sample/ords/migrate.js +++ b/templates/ords-remix-jwt-sample/ords/migrate.js @@ -8,66 +8,71 @@ import * as dotenv from 'dotenv'; import path from 'path'; import createSchema from './RESTfulServices/RESTSchema.js'; import createObjects from './migrateScripts/schemaObjects.js'; -import createModules from './migrateScripts/defineModules.js'; -import protectEndpoints from './migrateScripts/protectEndpoints.js'; -import autoRESTEnableObjects from './migrateScripts/autoRESTEnableObjects.js'; -import setConfig from './utils/enviroment.js'; +import { + closeConnection, + getConnection, +} from './utils/oracleConnection.js'; dotenv.config({ path: `${path.resolve()}/.env` }); -let { - ADB_ORDS_URL, + +const { + BD_CONNECT_STRING, SCHEMA_NAME, SCHEMA_PASSWORD, - ADB_ADMIN_USER, - ADB_ADMIN_PASSWORD, + BD_ADMIN_USER, + BD_ADMIN_PASSWORD, } = process.env; -let ORDS_ADMIN_AUTH_CREDENTIALS = `${ADB_ADMIN_USER}:${ADB_ADMIN_PASSWORD}`; -let BASIC_ADMIN_AUTH = `Basic ${Buffer.from(ORDS_ADMIN_AUTH_CREDENTIALS).toString('base64')}`; -let ORDS_SCHEMA_AUTH_CREDENTIALS = `${SCHEMA_NAME}:${SCHEMA_PASSWORD}`; -let BASIC_SCHEMA_AUTH = `Basic ${Buffer.from(ORDS_SCHEMA_AUTH_CREDENTIALS).toString('base64')}`; -let ADB_ADMIN_ENDPOINT = `${ADB_ORDS_URL}${ADB_ADMIN_USER.toLowerCase()}/_/sql`; -let ADB_SCHEMA_ENDPOINT = `${ADB_ORDS_URL}${SCHEMA_NAME.toLowerCase()}/_/sql`; - -const { JWT_ISSUER } = process.env; -const { JWT_VERIFICATION_KEY } = process.env; -const { JWT_AUDIENCE } = process.env; /** - * Uses CLI and .env to set environment constants. + * Returns a required environment variable and throws when missing. + * @param {string | undefined} value the variable value. + * @param {string} variableName the variable name. + * @returns {string} the non-empty variable value. */ -async function setEnvironment() { - const envConfig = await setConfig(); - if (Object.keys(envConfig).length > 0) { - ADB_ORDS_URL = envConfig.ADB_ORDS_URL || ADB_ORDS_URL; - SCHEMA_NAME = envConfig.SCHEMA_NAME || SCHEMA_NAME; - SCHEMA_PASSWORD = envConfig.SCHEMA_PASSWORD || SCHEMA_PASSWORD; - ADB_ADMIN_USER = envConfig.ADB_ADMIN_USER || ADB_ADMIN_USER; - ADB_ADMIN_PASSWORD = envConfig.ADB_ADMIN_PASSWORD || ADB_ADMIN_PASSWORD; - ORDS_ADMIN_AUTH_CREDENTIALS = `${ADB_ADMIN_USER}:${ADB_ADMIN_PASSWORD}`; - BASIC_ADMIN_AUTH = `Basic ${Buffer.from(ORDS_ADMIN_AUTH_CREDENTIALS).toString('base64')}`; - ORDS_SCHEMA_AUTH_CREDENTIALS = `${SCHEMA_NAME}:${SCHEMA_PASSWORD}`; - BASIC_SCHEMA_AUTH = `Basic ${Buffer.from(ORDS_SCHEMA_AUTH_CREDENTIALS).toString('base64')}`; - ADB_ADMIN_ENDPOINT = `${ADB_ORDS_URL}${ADB_ADMIN_USER.toLowerCase()}/_/sql`; - ADB_SCHEMA_ENDPOINT = `${ADB_ORDS_URL}${SCHEMA_NAME.toLowerCase()}/_/sql`; +function getRequiredEnvVar(value, variableName) { + if (!value || value.trim() === '') { + throw new Error(`Missing required environment variable: ${variableName}`); } + return value; } /** - * migrate script, creates the schema, objects, modules, and more. + * Migrate script: creates schema and schema objects using direct SQL. */ async function migrate() { - await setEnvironment(); - await createSchema(SCHEMA_NAME, SCHEMA_PASSWORD, ADB_ADMIN_ENDPOINT, BASIC_ADMIN_AUTH); - await createObjects(ADB_SCHEMA_ENDPOINT, BASIC_SCHEMA_AUTH); - await createModules(ADB_SCHEMA_ENDPOINT, BASIC_SCHEMA_AUTH); - await protectEndpoints( - ADB_SCHEMA_ENDPOINT, - BASIC_SCHEMA_AUTH, - JWT_ISSUER, - JWT_AUDIENCE, - JWT_VERIFICATION_KEY, - ); - await autoRESTEnableObjects(SCHEMA_NAME, ADB_SCHEMA_ENDPOINT, BASIC_SCHEMA_AUTH); + const connectString = getRequiredEnvVar(BD_CONNECT_STRING, 'BD_CONNECT_STRING'); + const schemaName = getRequiredEnvVar(SCHEMA_NAME, 'SCHEMA_NAME'); + const schemaPassword = getRequiredEnvVar(SCHEMA_PASSWORD, 'SCHEMA_PASSWORD'); + const adminUser = getRequiredEnvVar(BD_ADMIN_USER, 'BD_ADMIN_USER'); + const adminPassword = getRequiredEnvVar(BD_ADMIN_PASSWORD, 'BD_ADMIN_PASSWORD'); + + let adminConnection; + let schemaConnection; + + try { + adminConnection = await getConnection({ + user: adminUser, + password: adminPassword, + connectString, + }); + await createSchema(adminConnection, schemaName, schemaPassword); + + schemaConnection = await getConnection({ + user: schemaName, + password: schemaPassword, + connectString, + }); + await createObjects(schemaConnection); + // eslint-disable-next-line no-console + console.log(`Migrate completed successfully for schema ${schemaName}.`); + } finally { + await closeConnection(schemaConnection); + await closeConnection(adminConnection); + } } -migrate(); +migrate().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; +}); diff --git a/templates/ords-remix-jwt-sample/ords/migrateScripts/schemaObjects.js b/templates/ords-remix-jwt-sample/ords/migrateScripts/schemaObjects.js index f824ae3..5b33064 100644 --- a/templates/ords-remix-jwt-sample/ords/migrateScripts/schemaObjects.js +++ b/templates/ords-remix-jwt-sample/ords/migrateScripts/schemaObjects.js @@ -4,267 +4,206 @@ ** All rights reserved ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ */ -import { - postRequest, - printResponse, -} from '../utils/ORDSRequests.js'; +import { executeWithLogging } from '../utils/sqlExecutionLogger.js'; -/** - * Creates the schema objects (tables, views, etc). - * Use the schema owner credentials. - * @param {string} endpoint the ADB-S admin endpoint. - * @param {string} basicAuth the authentication string for the admin user. - */ -async function createObjects(endpoint, basicAuth) { - const artistStatements = ` - CREATE TABLE ARTISTS( - ARTIST_ID NUMBER(10) NOT NULL CONSTRAINT ARTISTS_PK PRIMARY KEY, - NAME VARCHAR(100) NOT NULL, - DESCRIPTION VARCHAR(300) NOT NULL, - BIO VARCHAR(2000) NOT NULL - - ); - - CREATE SEQUENCE ARTIST_SEQ - START WITH 200 - INCREMENT BY 1 - NOMAXVALUE - NOMINVALUE - NOCYCLE - CACHE 10; - - CREATE OR REPLACE TRIGGER ARTIST_TRIG +const SCHEMA_DDL_STATEMENTS = [ + `CREATE TABLE ARTISTS ( + ARTIST_ID NUMBER(10) NOT NULL CONSTRAINT ARTISTS_PK PRIMARY KEY, + NAME VARCHAR2(100) NOT NULL, + DESCRIPTION VARCHAR2(300) NOT NULL, + BIO VARCHAR2(2000) NOT NULL + )`, + `CREATE SEQUENCE ARTIST_SEQ + START WITH 200 + INCREMENT BY 1 + NOMAXVALUE + NOMINVALUE + NOCYCLE + CACHE 10`, + `CREATE OR REPLACE TRIGGER ARTIST_TRIG BEFORE INSERT ON ARTISTS FOR EACH ROW - BEGIN - SELECT ARTIST_SEQ.NEXTVAL - INTO :NEW.ARTIST_ID - FROM DUAL; - END; - / - `; - const musicGenreStatements = ` - CREATE TABLE MUSIC_GENRES( - MUSIC_GENRE_ID NUMBER(10) NOT NULL CONSTRAINT MUSIC_GENRES_MUSIC_GENRE_ID_PK PRIMARY KEY, - NAME VARCHAR(100) NOT NULL, - DESCRIPTION VARCHAR(500) NOT NULL - ); - - CREATE SEQUENCE MUSIC_GENRES_SEQ - START WITH 1 - INCREMENT BY 1 - NOMAXVALUE - NOMINVALUE - NOCYCLE - CACHE 10; - - CREATE OR REPLACE TRIGGER MUSIC_GENRE_TRIG - BEFORE INSERT ON MUSIC_GENRES - FOR EACH ROW - BEGIN - SELECT MUSIC_GENRES_SEQ.NEXTVAL - INTO :NEW.MUSIC_GENRE_ID - FROM DUAL; - END; - / - `; - const citiesStatements = ` - CREATE TABLE CITIES( - CITY_ID NUMBER(10) NOT NULL CONSTRAINT CITIES_CITY_ID_PK PRIMARY KEY, - NAME VARCHAR(100) NOT NULL, - DESCRIPTION VARCHAR(500) NOT NULL - ); - - CREATE SEQUENCE CITIES_SEQ - START WITH 100 - INCREMENT BY 1 - NOMAXVALUE - NOMINVALUE - NOCYCLE - CACHE 10; - - CREATE OR REPLACE TRIGGER CITIES_TRIG - BEFORE INSERT ON CITIES - FOR EACH ROW - + SELECT ARTIST_SEQ.NEXTVAL + INTO :NEW.ARTIST_ID + FROM DUAL; + END;`, + `CREATE TABLE MUSIC_GENRES ( + MUSIC_GENRE_ID NUMBER(10) NOT NULL CONSTRAINT MUSIC_GENRES_MUSIC_GENRE_ID_PK PRIMARY KEY, + NAME VARCHAR2(100) NOT NULL, + DESCRIPTION VARCHAR2(500) NOT NULL + )`, + `CREATE SEQUENCE MUSIC_GENRES_SEQ + START WITH 1 + INCREMENT BY 1 + NOMAXVALUE + NOMINVALUE + NOCYCLE + CACHE 10`, + `CREATE OR REPLACE TRIGGER MUSIC_GENRE_TRIG + BEFORE INSERT ON MUSIC_GENRES + FOR EACH ROW BEGIN - SELECT CITIES_SEQ.NEXTVAL - INTO :NEW.CITY_ID - FROM DUAL; - END; - / - `; - const venueStatements = ` - CREATE TABLE VENUES( - VENUE_ID NUMBER(10) NOT NULL CONSTRAINT VENUES_VENUE_ID_PK PRIMARY KEY, - NAME VARCHAR(100) NOT NULL, - LOCATION VARCHAR(200) NOT NULL, - CITY_ID NUMBER(10) NOT NULL, - CONSTRAINT VENUES_CITY_ID_FK FOREIGN KEY(CITY_ID) - REFERENCES CITIES(CITY_ID) - - ); - CREATE SEQUENCE VENUES_SEQ + SELECT MUSIC_GENRES_SEQ.NEXTVAL + INTO :NEW.MUSIC_GENRE_ID + FROM DUAL; + END;`, + `CREATE TABLE CITIES ( + CITY_ID NUMBER(10) NOT NULL CONSTRAINT CITIES_CITY_ID_PK PRIMARY KEY, + NAME VARCHAR2(100) NOT NULL, + DESCRIPTION VARCHAR2(500) NOT NULL + )`, + `CREATE SEQUENCE CITIES_SEQ START WITH 100 INCREMENT BY 1 NOMAXVALUE NOMINVALUE NOCYCLE - CACHE 10; - - CREATE OR REPLACE TRIGGER VENUES_TRIG + CACHE 10`, + `CREATE OR REPLACE TRIGGER CITIES_TRIG + BEFORE INSERT ON CITIES + FOR EACH ROW + BEGIN + SELECT CITIES_SEQ.NEXTVAL + INTO :NEW.CITY_ID + FROM DUAL; + END;`, + `CREATE TABLE VENUES ( + VENUE_ID NUMBER(10) NOT NULL CONSTRAINT VENUES_VENUE_ID_PK PRIMARY KEY, + NAME VARCHAR2(100) NOT NULL, + LOCATION VARCHAR2(200) NOT NULL, + CITY_ID NUMBER(10) NOT NULL, + CONSTRAINT VENUES_CITY_ID_FK FOREIGN KEY (CITY_ID) + REFERENCES CITIES (CITY_ID) + )`, + `CREATE SEQUENCE VENUES_SEQ + START WITH 100 + INCREMENT BY 1 + NOMAXVALUE + NOMINVALUE + NOCYCLE + CACHE 10`, + `CREATE OR REPLACE TRIGGER VENUES_TRIG BEFORE INSERT ON VENUES FOR EACH ROW - BEGIN - SELECT VENUES_SEQ.NEXTVAL - INTO :NEW.VENUE_ID - FROM DUAL; - END; - / - `; - - const eventStatusStatements = ` - CREATE TABLE EVENT_STATUS( - EVENT_STATUS_ID NUMBER(10) CONSTRAINT EVENT_STATUS_PK PRIMARY KEY, - EVENT_STATUS_NAME VARCHAR2(40) NOT NULL, - EVENT_STATUS_DESCRIPTION VARCHAR2(100) NOT NULL - ); - `; - - const eventStatements = ` - CREATE TABLE EVENTS( - EVENT_ID NUMBER(10) NOT NULL CONSTRAINT EVENTS_EVENT_ID_PK PRIMARY KEY, - EVENT_DATE DATE NOT NULL, - ARTIST_ID NUMBER(10) NOT NULL, - VENUE_ID NUMBER(10) NOT NULL, - EVENT_STATUS_ID NUMBER(10) NOT NULL, - EVENT_DETAILS VARCHAR2(400) NOT NULL, - CONSTRAINT EVENTS_ARTIST_ID_FK FOREIGN KEY(ARTIST_ID) - REFERENCES ARTISTS(ARTIST_ID), - CONSTRAINT EVENTS_VENUE_ID_FK FOREIGN KEY(VENUE_ID) - REFERENCES VENUES(VENUE_ID), - CONSTRAINT EVENTS_EVENT_STATUS_ID_FK FOREIGN KEY(EVENT_STATUS_ID) - REFERENCES EVENT_STATUS(EVENT_STATUS_ID) - ); - - CREATE SEQUENCE EVENTS_SEQ + BEGIN + SELECT VENUES_SEQ.NEXTVAL + INTO :NEW.VENUE_ID + FROM DUAL; + END;`, + `CREATE TABLE EVENT_STATUS ( + EVENT_STATUS_ID NUMBER(10) CONSTRAINT EVENT_STATUS_PK PRIMARY KEY, + EVENT_STATUS_NAME VARCHAR2(40) NOT NULL, + EVENT_STATUS_DESCRIPTION VARCHAR2(100) NOT NULL + )`, + `CREATE TABLE EVENTS ( + EVENT_ID NUMBER(10) NOT NULL CONSTRAINT EVENTS_EVENT_ID_PK PRIMARY KEY, + EVENT_DATE DATE NOT NULL, + ARTIST_ID NUMBER(10) NOT NULL, + VENUE_ID NUMBER(10) NOT NULL, + EVENT_STATUS_ID NUMBER(10) NOT NULL, + EVENT_DETAILS VARCHAR2(400) NOT NULL, + CONSTRAINT EVENTS_ARTIST_ID_FK FOREIGN KEY (ARTIST_ID) + REFERENCES ARTISTS (ARTIST_ID), + CONSTRAINT EVENTS_VENUE_ID_FK FOREIGN KEY (VENUE_ID) + REFERENCES VENUES (VENUE_ID), + CONSTRAINT EVENTS_EVENT_STATUS_ID_FK FOREIGN KEY (EVENT_STATUS_ID) + REFERENCES EVENT_STATUS (EVENT_STATUS_ID) + )`, + `CREATE SEQUENCE EVENTS_SEQ START WITH 1 INCREMENT BY 1 NOMAXVALUE NOMINVALUE NOCYCLE - CACHE 10; - - CREATE OR REPLACE TRIGGER EVENTS_TRIG + CACHE 10`, + `CREATE OR REPLACE TRIGGER EVENTS_TRIG BEFORE INSERT ON EVENTS FOR EACH ROW - BEGIN - SELECT EVENTS_SEQ.NEXTVAL - INTO :NEW.EVENT_ID - FROM DUAL; - END; - / - `; - - const classificationStatements = ` - CREATE TABLE ARTIST_CLASSIFICATIONS( - ARTIST_ID NUMBER(10) NOT NULL, - MUSIC_GENRE_ID NUMBER(10) NOT NULL, - CONSTRAINT ARTIST_CLASSIFICATIONS_ARTIST_ID_FK FOREIGN KEY(ARTIST_ID) - REFERENCES ARTISTS(ARTIST_ID), - CONSTRAINT ARTIST_CLASSIFICATIONS_MUSIC_GENRE_ID_FK FOREIGN KEY(MUSIC_GENRE_ID) - REFERENCES MUSIC_GENRES(MUSIC_GENRE_ID) - ); - `; - const likedArtistStatements = ` - CREATE TABLE LIKED_ARTIST( - USER_ID VARCHAR2(60) NOT NULL, - ARTIST_ID NUMBER(10) NOT NULL, - CONSTRAINT LIKED_ARTIST_ARTIST_ID_FK FOREIGN KEY(ARTIST_ID) - REFERENCES ARTISTS(ARTIST_ID), - CONSTRAINT LIKED_ARTISTS_PK PRIMARY KEY(USER_ID, ARTIST_ID) - ); - `; - const likedMusicGenresStatements = ` - CREATE TABLE LIKED_MUSIC_GENRES( - USER_ID VARCHAR2(60) NOT NULL, - MUSIC_GENRE_ID NUMBER(10) NOT NULL, - CONSTRAINT LIKED_MUSIC_GENRE_ARTIST_ID_FK FOREIGN KEY(MUSIC_GENRE_ID) - REFERENCES MUSIC_GENRES(MUSIC_GENRE_ID), - CONSTRAINT LIKED_MUSIC_GENRES_PK PRIMARY KEY(USER_ID, MUSIC_GENRE_ID) - ); - `; - const likedVenueStatements = ` - CREATE TABLE LIKED_VENUE( - USER_ID VARCHAR2(60) NOT NULL, - VENUE_ID NUMBER(10) NOT NULL, - CONSTRAINT LIKED_VENUE_VENUE_ID_FK FOREIGN KEY(VENUE_ID) - REFERENCES VENUES(VENUE_ID), - CONSTRAINT LIKED_VENUE_PK PRIMARY KEY(USER_ID, VENUE_ID) - ); - - `; - const likedEventStatements = ` - CREATE TABLE LIKED_EVENT( - USER_ID VARCHAR2(60) NOT NULL, - EVENT_ID NUMBER(10) NOT NULL, - CONSTRAINT LIKED_EVENT_EVENT_ID_FK FOREIGN KEY(EVENT_ID) - REFERENCES EVENTS(EVENT_ID), - CONSTRAINT LIKED_EVENT_PK PRIMARY KEY(USER_ID, EVENT_ID) - ); - `; - const ticketStatements = ` - CREATE TABLE TICKET( - EVENT_ID NUMBER(10) NOT NULL, - USER_ID VARCHAR2(60) NOT NULL, - CONSTRAINT TICKET_EVENT_ID_FK FOREIGN KEY(EVENT_ID) - REFERENCES EVENTS(EVENT_ID), - CONSTRAINT TICKET_PK PRIMARY KEY(EVENT_ID, USER_ID) - ); - `; - const statsStatement = `CREATE VIEW BANNER_VIEW AS + BEGIN + SELECT EVENTS_SEQ.NEXTVAL + INTO :NEW.EVENT_ID + FROM DUAL; + END;`, + `CREATE TABLE ARTIST_CLASSIFICATIONS ( + ARTIST_ID NUMBER(10) NOT NULL, + MUSIC_GENRE_ID NUMBER(10) NOT NULL, + CONSTRAINT ARTIST_CLASSIFICATIONS_ARTIST_ID_FK FOREIGN KEY (ARTIST_ID) + REFERENCES ARTISTS (ARTIST_ID), + CONSTRAINT ARTIST_CLASSIFICATIONS_MUSIC_GENRE_ID_FK FOREIGN KEY (MUSIC_GENRE_ID) + REFERENCES MUSIC_GENRES (MUSIC_GENRE_ID) + )`, + `CREATE TABLE LIKED_ARTIST ( + USER_ID VARCHAR2(60) NOT NULL, + ARTIST_ID NUMBER(10) NOT NULL, + CONSTRAINT LIKED_ARTIST_ARTIST_ID_FK FOREIGN KEY (ARTIST_ID) + REFERENCES ARTISTS (ARTIST_ID), + CONSTRAINT LIKED_ARTISTS_PK PRIMARY KEY (USER_ID, ARTIST_ID) + )`, + `CREATE TABLE LIKED_MUSIC_GENRES ( + USER_ID VARCHAR2(60) NOT NULL, + MUSIC_GENRE_ID NUMBER(10) NOT NULL, + CONSTRAINT LIKED_MUSIC_GENRE_ARTIST_ID_FK FOREIGN KEY (MUSIC_GENRE_ID) + REFERENCES MUSIC_GENRES (MUSIC_GENRE_ID), + CONSTRAINT LIKED_MUSIC_GENRES_PK PRIMARY KEY (USER_ID, MUSIC_GENRE_ID) + )`, + `CREATE TABLE LIKED_VENUE ( + USER_ID VARCHAR2(60) NOT NULL, + VENUE_ID NUMBER(10) NOT NULL, + CONSTRAINT LIKED_VENUE_VENUE_ID_FK FOREIGN KEY (VENUE_ID) + REFERENCES VENUES (VENUE_ID), + CONSTRAINT LIKED_VENUE_PK PRIMARY KEY (USER_ID, VENUE_ID) + )`, + `CREATE TABLE LIKED_EVENT ( + USER_ID VARCHAR2(60) NOT NULL, + EVENT_ID NUMBER(10) NOT NULL, + CONSTRAINT LIKED_EVENT_EVENT_ID_FK FOREIGN KEY (EVENT_ID) + REFERENCES EVENTS (EVENT_ID), + CONSTRAINT LIKED_EVENT_PK PRIMARY KEY (USER_ID, EVENT_ID) + )`, + `CREATE TABLE TICKET ( + EVENT_ID NUMBER(10) NOT NULL, + USER_ID VARCHAR2(60) NOT NULL, + CONSTRAINT TICKET_EVENT_ID_FK FOREIGN KEY (EVENT_ID) + REFERENCES EVENTS (EVENT_ID), + CONSTRAINT TICKET_PK PRIMARY KEY (EVENT_ID, USER_ID) + )`, + `CREATE VIEW BANNER_VIEW AS SELECT - (SELECT COUNT(*) FROM events) AS events_count, - (SELECT COUNT(*) FROM artists) AS artists_count, - (SELECT COUNT(*) FROM venues) AS venues_count - FROM DUAL;`; - - const eventsViewStatement = `CREATE VIEW EVENTS_VIEW AS - SELECT - A.NAME AS ARTIST_NAME, - A.ARTIST_ID AS ARTIST_ID, - E.EVENT_ID AS EVENT_ID, - E.EVENT_DATE AS EVENT_DATE, - E.EVENT_DETAILS AS EVENT_DETAILS, - ES.EVENT_STATUS_NAME AS EVENT_STATUS_NAME, - ES.EVENT_STATUS_ID AS EVENT_STATUS_ID, - V.VENUE_ID AS VENUE_ID, - V.NAME AS VENUE_NAME, - C.NAME AS CITY_NAME, - AGG.MUSIC_GENRES - - FROM - EVENTS E - INNER JOIN ARTISTS A ON E.ARTIST_ID = A.ARTIST_ID - INNER JOIN EVENT_STATUS ES ON E.EVENT_STATUS_ID = ES.EVENT_STATUS_ID - INNER JOIN VENUES V ON E.VENUE_ID = V.VENUE_ID - INNER JOIN CITIES C ON V.CITY_ID = C.CITY_ID - LEFT JOIN ( - SELECT - AA.ARTIST_ID, - LISTAGG(MG.NAME, ', ') WITHIN GROUP (ORDER BY MG.NAME) AS MUSIC_GENRES - FROM - ARTIST_CLASSIFICATIONS AA - INNER JOIN MUSIC_GENRES MG ON AA.MUSIC_GENRE_ID = MG.MUSIC_GENRE_ID - GROUP BY - AA.ARTIST_ID + (SELECT COUNT(*) FROM EVENTS) AS EVENTS_COUNT, + (SELECT COUNT(*) FROM ARTISTS) AS ARTISTS_COUNT, + (SELECT COUNT(*) FROM VENUES) AS VENUES_COUNT + FROM DUAL`, + `CREATE VIEW EVENTS_VIEW AS + SELECT + A.NAME AS ARTIST_NAME, + A.ARTIST_ID AS ARTIST_ID, + E.EVENT_ID AS EVENT_ID, + E.EVENT_DATE AS EVENT_DATE, + E.EVENT_DETAILS AS EVENT_DETAILS, + ES.EVENT_STATUS_NAME AS EVENT_STATUS_NAME, + ES.EVENT_STATUS_ID AS EVENT_STATUS_ID, + V.VENUE_ID AS VENUE_ID, + V.NAME AS VENUE_NAME, + C.NAME AS CITY_NAME, + AGG.MUSIC_GENRES + FROM EVENTS E + INNER JOIN ARTISTS A ON E.ARTIST_ID = A.ARTIST_ID + INNER JOIN EVENT_STATUS ES ON E.EVENT_STATUS_ID = ES.EVENT_STATUS_ID + INNER JOIN VENUES V ON E.VENUE_ID = V.VENUE_ID + INNER JOIN CITIES C ON V.CITY_ID = C.CITY_ID + LEFT JOIN ( + SELECT + AA.ARTIST_ID, + LISTAGG(MG.NAME, ', ') WITHIN GROUP (ORDER BY MG.NAME) AS MUSIC_GENRES + FROM ARTIST_CLASSIFICATIONS AA + INNER JOIN MUSIC_GENRES MG ON AA.MUSIC_GENRE_ID = MG.MUSIC_GENRE_ID + GROUP BY AA.ARTIST_ID ) AGG ON A.ARTIST_ID = AGG.ARTIST_ID - ORDER BY E.EVENT_DATE DESC;`; - - const searchViewStatement = `CREATE VIEW SEARCH_VIEW AS - SELECT - A.NAME || ' at ' || V.NAME || ', ' || C.NAME as EVENT_NAME, + ORDER BY E.EVENT_DATE DESC`, + `CREATE VIEW SEARCH_VIEW AS + SELECT + A.NAME || ' at ' || V.NAME || ', ' || C.NAME AS EVENT_NAME, A.NAME AS ARTIST_NAME, A.ARTIST_ID AS ARTIST_ID, E.EVENT_ID AS EVENT_ID, @@ -276,57 +215,54 @@ async function createObjects(endpoint, basicAuth) { V.NAME AS VENUE_NAME, C.NAME AS CITY_NAME, AGG.MUSIC_GENRES - FROM - EVENTS E + FROM EVENTS E INNER JOIN ARTISTS A ON E.ARTIST_ID = A.ARTIST_ID INNER JOIN EVENT_STATUS ES ON E.EVENT_STATUS_ID = ES.EVENT_STATUS_ID INNER JOIN VENUES V ON E.VENUE_ID = V.VENUE_ID INNER JOIN CITIES C ON V.CITY_ID = C.CITY_ID LEFT JOIN ( - SELECT - AA.ARTIST_ID, - LISTAGG(MG.NAME, ', ') WITHIN GROUP (ORDER BY MG.NAME) AS MUSIC_GENRES - FROM - ARTIST_CLASSIFICATIONS AA - INNER JOIN MUSIC_GENRES MG ON AA.MUSIC_GENRE_ID = MG.MUSIC_GENRE_ID - GROUP BY - AA.ARTIST_ID + SELECT + AA.ARTIST_ID, + LISTAGG(MG.NAME, ', ') WITHIN GROUP (ORDER BY MG.NAME) AS MUSIC_GENRES + FROM ARTIST_CLASSIFICATIONS AA + INNER JOIN MUSIC_GENRES MG ON AA.MUSIC_GENRE_ID = MG.MUSIC_GENRE_ID + GROUP BY AA.ARTIST_ID ) AGG ON A.ARTIST_ID = AGG.ARTIST_ID - ORDER BY - E.EVENT_DATE;`; - - const searchArtistViewStatement = `CREATE VIEW SEARCH_ARTIST_VIEW AS + ORDER BY E.EVENT_DATE`, + `CREATE VIEW SEARCH_ARTIST_VIEW AS SELECT A.ARTIST_ID, A.NAME, A.DESCRIPTION, - LISTAGG(MG.NAME, ', ') WITHIN GROUP( - ORDER BY - MG.NAME - ) AS MUSIC_GENRES - FROM - ARTISTS A + LISTAGG(MG.NAME, ', ') WITHIN GROUP (ORDER BY MG.NAME) AS MUSIC_GENRES + FROM ARTISTS A LEFT JOIN ARTIST_CLASSIFICATIONS AA ON A.ARTIST_ID = AA.ARTIST_ID - LEFT JOIN MUSIC_GENRES MG ON AA.MUSIC_GENRE_ID = MG.MUSIC_GENRE_ID + LEFT JOIN MUSIC_GENRES MG ON AA.MUSIC_GENRE_ID = MG.MUSIC_GENRE_ID GROUP BY A.ARTIST_ID, A.NAME, A.DESCRIPTION - ORDER BY - A.ARTIST_ID;`; - - const searchVenuesViewStatement = `CREATE VIEW SEARCH_VENUES_VIEW AS - SELECT V.*, C.NAME AS CITY_NAME - FROM VENUES V JOIN CITIES C - ON V.CITY_ID = C.CITY_ID;`; + ORDER BY A.ARTIST_ID`, + `CREATE VIEW SEARCH_VENUES_VIEW AS + SELECT + V.*, + C.NAME AS CITY_NAME + FROM VENUES V + JOIN CITIES C ON V.CITY_ID = C.CITY_ID`, +]; - const statements = [artistStatements, musicGenreStatements, citiesStatements, - venueStatements, eventStatusStatements, eventStatements, classificationStatements, - likedArtistStatements, likedMusicGenresStatements, - likedVenueStatements, likedEventStatements, ticketStatements, statsStatement, - eventsViewStatement, searchViewStatement, searchArtistViewStatement, searchVenuesViewStatement].join(' '); - const statementsResponse = await postRequest(endpoint, statements, basicAuth); - printResponse(statementsResponse); +/** + * Creates schema objects required by the sample app. + * @param {import('oracledb').Connection} connection the schema owner connection. + */ +async function createObjects(connection) { + await SCHEMA_DDL_STATEMENTS.reduce( + async (previousExecution, statement) => { + await previousExecution; + await executeWithLogging(connection, statement); + }, + Promise.resolve(), + ); } export default createObjects; diff --git a/templates/ords-remix-jwt-sample/ords/security/README.MD b/templates/ords-remix-jwt-sample/ords/security/README.MD index f57e6ca..e01350e 100644 --- a/templates/ords-remix-jwt-sample/ords/security/README.MD +++ b/templates/ords-remix-jwt-sample/ords/security/README.MD @@ -1,231 +1,100 @@ # ORDS Concert App - Securing the Services -When developing REST APIs, it's crucial to protect endpoints and separate use cases to ensure security, data integrity, and proper access control. Mainly because it allows us to: +This guide explains how the sample app secures ORDS endpoints using JWT bearer tokens and OIDC login in the Remix frontend. -- Prevent Unauthorized Access: By protecting the ORDS Concert App endpoints, we make sure that only authorized users can access sensitive information and perform specific actions. For example, only admin users should have access to endpoints that modifies or deletes data from our tables. +## Security Model -- Control Data Operations: Ensuring that only certain users can perform specific operations (e.g., data updates or deletions) prevents accidental or intentional data manipulation by unauthorized users. +The API is split into three ORDS modules: -- Improve User Experience: By segmenting access based on users, we can provide a more tailored and relevant experience to different types of users. Admins get access to management features, while regular users get a simplified interface. +| Module | Pattern | Access | +|---|---|---| +| `concert_app.euser.v1` | `/euser/v1/*` | Public/anonymous browse endpoints | +| `concert_app.authuser.v1` | `/authuser/v1/*` | Authenticated user endpoints | +| `concert_app.adminuser.v1` | `/adminuser/v1/*` | Administrator endpoints | -- Error Prevention: Restricting access helps prevent users from encountering functions they shouldn't use, reducing the chance of user errors. +Privileges created by migration scripts: -Now consider the ORDS Concert App Use case in which we limit the operations that a user can perform based on the kind of user that is making the request: +- `concert_app_authuser` +- `concert_app_admin` -- Authenticated End User (authuser): Should be able to view concert details and subscribe to them but should not access admin functions. +These privileges are attached to protected modules during `npm run migrate`. -- Admin User (adminuser): Should have access to manage concerts, update event details, and handle user issues. +## ORDS JWT Profile -- Non-Authenticated End User (euser): Should have limited access to browse concerts but not perform actions that require authentication. - -To protect our ORDS API endpoints we will implement the [JWT Bearer Token Authentication Workflow](https://docs.oracle.com/en/database/oracle/oracle-rest-data-services/24.2/orddg/developing-REST-applications.html#GUID-32E8F21C-A2CA-4DB5-B1BB-9E5119FCB667) - -## Protecting our API Endpoints - -First, let's see how the ORDS Concert App Modules are Defined: - -| MODULE NAME | PATTERN | COMMENTS | -|------------ | ------- | -------- | -|'concert_app.euser.v1' | /euser/v1/* | Provides access to the first version of the End User RESTful API Endpoints | -|'concert_app.authuser.v1' | /authuser/v1/* | Provides access to the first version of the Authenticated User RESTful API Endpoints | -|'concert_app.admin.v1' | /adminuser/v1/* | Provides access to the first version of the Administrator RESTful API Endpoints | - -The key part here is the Module Pattern and how it can be used to define an ORDS privilege. - -ORDS allows us to define [Privileges](https://docs.oracle.com/en/database/oracle/oracle-rest-data-services/24.2/orddg/oracle-rest-data-services-administration-pl-sql-package-reference.html#GUID-460E2355-A25A-4BE8-BF1F-3221A91147F7). Privileges are used to control access to specific endpoints. A privilege pattern allows us to define which users or roles can access certain URLs, ensuring that only authorized users can perform specific actions on the ORDS Concert App API Endpoints. - -Now, we need to define two privileges, one for each kind of user the ORDS Sample App has: - -```sql - DECLARE - l_roles OWA.VC_ARR; - l_modules OWA.VC_ARR; - l_patterns OWA.VC_ARR; - BEGIN - l_modules(1) := 'concert_app.authuser.v1'; - - ORDS.DEFINE_PRIVILEGE( - p_privilege_name => 'concert_app_authuser', - p_roles => l_roles, - p_patterns => l_patterns, - p_modules => l_modules, - p_label => 'authenticated end user privilege', - p_description => 'Provides access to the user specific endpoints', - p_comments => NULL); - - l_roles.DELETE; - l_modules.DELETE; - l_patterns.DELETE; - - l_modules(1) := 'concert_app.adminuser.v1'; - - ORDS.DEFINE_PRIVILEGE( - p_privilege_name => 'concert_app_admin', - p_roles => l_roles, - p_patterns => l_patterns, - p_modules => l_modules, - p_label => 'Admin user privilege', - p_description => 'Provides access to the concert app admin endpoints', - p_comments => NULL); - - l_roles.DELETE; - l_modules.DELETE; - l_patterns.DELETE; - - COMMIT; - END; -``` - -Note that by defining a the `/adminuser/*` `/authuser/*` we are protecting every single one of the handlers that belong to that Module, even modules that are part of specific versions of out API. This is because those patterns will always match the beginning of the API endpoint which coincides with the Module Section of the API Endpoint. - -At this stage, our API Endpoints are secured and this is great, however, now we need a way to grant users access to such resources and for that we will need to define an application capable of issuing JWT Tokens and tell ORDS to consume and validate tokens coming from that application. - -For the ORDS Concert App we will be using an Auth0 Machine to Machine Application. You can follow [The- Auth0 Application Configuration Guide](#the-auth0-application-configuration-guide) to fully configure an Authentication Application that the ORDS Concert App will use to authenticate and authorize users. - -Once you have the Authentication App configured and the configuration parameters properly stored in environmental variables, we can proceed with the creation of the JWT Client: +The migration flow configures ORDS JWT validation through: ```sql BEGIN OAUTH.DELETE_JWT_PROFILE(); OAUTH.CREATE_JWT_PROFILE( - p_issuer => , - p_audience => , - p_jwk_url => + p_issuer => '', + p_audience => '', + p_jwk_url => '' ); COMMIT; END; +/ ``` -With this step completed, the Sample App is now capable of: - -- Register new users. -- Identifying existing users and their resources (like liked events, artists and venues). -- Allow authenticated users to perform subscriptions. -- Give Administrator users full control over the ORDS Concert App resources. - -## The Auth0 Application Configuration Guide - -### First Step: Get an Auth0 Tenant. - -Follow the [Official Auth0 Documentation](https://auth0.com/docs/get-started/auth0-overview/create-tenants) to set up your own Auth0 tenant. - -Once you have your own Auth0 tenant configured and ready to go, you can configure an Auth0 application that the ORDS Sample App will consume. - -### Setting up the Auth0 App - -The ORDS Concert App uses a Machine-to-Machine workflow to register and authenticate users with the help of JWT tokens and for that we need to configure an Auth0 API and an Auth0 Machine-to-Machine App with an specific set of configurations so the app can work as intended. - -### Second Step: Register an Auth0 Authentication API. - -First, we need to register an Auth0 Authentication API. Follow the [Register API Auth0 Guide](https://auth0.com/docs/get-started/auth0-overview/set-up-apis) to do it so using the following configuration that the ORDS Concert App expects: - -![](../../images/auth0Guide/Auth0_API_Config.png) - -The key part here is the API Identifier, you can use whichever name you want and it should be stored in the `JWT_AUDIENCE` environment variable of your `.env` file. - -```bash -JWT_AUDIENCE=https://concert.sample.app -``` - -This is the audience that both the ORDS Sample App and the ORDS JWT Client will use to request and validate JSON Web Tokens. - -### Third Step: Grant permissions to the recently created API. - -Now that we have created our API, go to the `Permissions` tab and grant it the following permissions/privileges. - -> [!IMPORTANT] -> Replace with the `SCHEMA_NAME` variable specified on your `.env` file. - -| Permission | Description | What it does in the ORDS Sample App | -| ---------- | ----------- | ----------- | -|read:general_user_content | Read all of the general user endpoints | Allows the Sample App to read user information like first name, last name, email and Auth0 ID | -| concert_app_authuser | Provides access to the user specific endpoints | Allows authenticated users to interact with user specific ORDS Endpoints, like subscribing to a concert and more. | -| concert_app_admin | Provides access to the concert app admin endpoints | Allows authenticated admin users to interact with user specific ORDS Endpoints, like the create or delete Artist Endpoints. | - -Once you have added all of the permissions described above your list of permissions should look like this. - -![](../../images/auth0Guide/Auth0_API_Permissions.png) - -Once you have the app configured, we can go to the next step: configure a Machine-to-Machine Auth0 Application. - -### Fourth Step: Configure a Machine-to-Machine Application. - -To do it so, follow the [Register Machine-to-Machine Applications Auth0 Guide](https://auth0.com/docs/get-started/auth0-overview/create-applications/machine-to-machine-apps) up until the point where you need to select the API that will be using from this app and use Auth0 API that you created and configured in the [Second Step](#second-step-register-an-auth0-authentication-api): - -![](../../images/auth0Guide/Auth0_MtM_App.png) - -Once you have selected the Auth0 API, grant it all of the `ORDS_Concert_App` permissions to the application to ensure that an authenticated user will have full access to the application functionalities. - -To finish this step, click on the `Authorize` to create your Machine-to-Machine app and go to the next step. - -### Fifth Step: Add Auth0 configuration settings to your `.env` file. - -With the Machine to Machine App created, go to the `Settings` tab and store the following information in the corresponding environmental variables of your `.env` file: - -```bash -AUTH0_DOMAIN=my-domain.auth0.com -AUTH0_LOGOUT_URL=https://my-domain.auth0.com/v2/logout -JWT_ISSUER=https://my-domain.auth0.com/ -AUTH0_CLIENT_ID= -AUTH0_CLIENT_SECRET= -``` - -![](../../images/auth0Guide/Auth0_MtM_Settings.png) - -In that same `settings` page scroll down to `Application URIs` and configure `Allowed Callback URLs` and `Allowed Logout URLs` as follows: - -```bash -Allowed Logout URLs = http://localhost:3000 -Allowed Callback URLs = http://localhost:3000/callback -``` - -![](../../images/auth0Guide/Auth0_MtM_URI.png) - -Store those same variables in the `AUTH0_RETURN_TO_URL` and `AUTH0_CALLBACK_URL` environment variables of your `.env` file: +Set these values in `.env` before running `npm run migrate`: ```bash -AUTH0_RETURN_TO_URL=http://localhost:3000 -AUTH0_CALLBACK_URL=http://localhost:3000/callback +JWT_ISSUER=https://identity.oraclecloud.com/ +JWT_VERIFICATION_KEY=https://:443/admin/v1/SigningCert/jwk +JWT_AUDIENCE=ords/sample-app/ ``` -Note: The URL and port specified in these fields must match with ones used by the ORDS Concert App, by default Remix will try to use the `3000` port to serve the app but if that port is being used by other app another port will be chose at random, you may need to add more URLS to those fields. +## OIDC Setup (OCI IAM Target) -For the rest of this section you can leave everything as it is or configure it as you see fit. +The frontend now uses OIDC (`remix-auth-oauth2`) and the `/oidc` login route. -### Sixth Step: Configure the application grant type. +### 1. Create a confidential OIDC application -Now, go down to the `Advanced Settings` section and under grant types choose `Authorization Code` as it shows in the image above: +In OCI IAM Identity Domains, create an application for this sample app with: -![](../../images/auth0Guide/Auth0_MtM_AS.png) +- Authorization code flow +- Redirect URI: `http://localhost:3000/callback` +- Post logout redirect URI: `http://localhost:3000` +- Scopes including: + - `openid` + - `email` + - `profile` + - `ords/sample-app/concert_app_authuser` + - `ords/sample-app/concert_app_admin` -### Seventh Step: Configure the `JWT_VERIFICATION_KEY` variable. - -Stay in `Advanced Settings` and choose the `Endpoints` option and store the `JSON Web Key Set` value in the `JWT_VERIFICATION_KEY` environmental variable in your `.env` file: - -![](../../images/auth0Guide/Auth0_MtM_Endpoints.png) +### 2. Configure `.env` ```bash -JWT_VERIFICATION_KEY= +OIDC_RETURN_TO_URL=http://localhost:3000 +OIDC_REDIRECT_URI=http://localhost:3000/callback +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_AUTHORIZATION_ENDPOINT=https://:443/oauth2/v1/authorize +OIDC_TOKEN_ENDPOINT=https://:443/oauth2/v1/token +OIDC_USERINFO_ENDPOINT=https://:443/oauth2/v1/userinfo +OIDC_LOGOUT_ENDPOINT=https://:443/oauth2/v1/userlogout +OIDC_AUDIENCE=ords/sample-app/ +OIDC_SCOPES=openid,email,profile,ords/sample-app/concert_app_authuser,ords/sample-app/concert_app_admin ``` -Don't forget to save your changes before going to the last step. +## Validation Checklist -### Eighth Step: Making sure the app can request access tokens +1. Run the app and sign in from the navbar (`POST /oidc`). +2. Confirm callback succeeds to `/private/profile`. +3. Call an authenticated endpoint and verify success with a valid token. +4. Call an admin endpoint with a non-admin token and confirm it is rejected. +5. Sign out and confirm provider logout + local session clear. -Finally, go to the API section and make sure that the Machine to Machine Application is authorized to request access tokens for these APIs by executing a client credentials exchange. +## Legacy Auth0 Compatibility -![](../../images/auth0Guide/Auth0_MtM_API.png) +The app still supports legacy Auth0 variable names as fallback for compatibility: -At this stage your `.env` file should look something like this: +- `AUTH0_RETURN_TO_URL` +- `AUTH0_CALLBACK_URL` +- `AUTH0_CLIENT_ID` +- `AUTH0_CLIENT_SECRET` +- `AUTH0_DOMAIN` +- `AUTH0_LOGOUT_URL` +- `JWT_AUDIENCE` -```bash -AUTH0_CLIENT_ID=auth0_client_id -AUTH0_CLIENT_SECRET=auth0_client_secret -JWT_ISSUER=https://dev-yourDomain1234.us.auth0.com -JWT_VERIFICATION_KEY=https://my-domain.auth0.com/oauth/token/.well-known/jwks.json -JWT_AUDIENCE=https://concert.sample.app -AUTH0_RETURN_TO_URL=http://localhost:3000 -AUTH0_CALLBACK_URL=http://localhost:3000/callback -AUTH0_DOMAIN=my-domain.auth0.com -AUTH0_LOGOUT_URL=https://my-domain.auth0.com/v2/logout -``` \ No newline at end of file +If these are present, the app can still build OIDC endpoints from `AUTH0_DOMAIN`. diff --git a/templates/ords-remix-jwt-sample/ords/seed.js b/templates/ords-remix-jwt-sample/ords/seed.js index dc3207d..ea8031d 100644 --- a/templates/ords-remix-jwt-sample/ords/seed.js +++ b/templates/ords-remix-jwt-sample/ords/seed.js @@ -7,24 +7,54 @@ import * as dotenv from 'dotenv'; import path from 'path'; import populateObjects from './seedScripts/batchload.js'; -import autoRESTDisableObjects from './migrateScripts/autoRESTDisableObjects.js'; +import { + closeConnection, + getConnection, +} from './utils/oracleConnection.js'; dotenv.config({ path: `${path.resolve()}/.env` }); -const { ADB_ORDS_URL } = process.env; -const { SCHEMA_NAME } = process.env; -const { SCHEMA_PASSWORD } = process.env; -const ORDS_SCHEMA_AUTH_CREDENTIALS = `${SCHEMA_NAME}:${SCHEMA_PASSWORD}`; -const BASIC_SCHEMA_AUTH = `Basic ${Buffer.from(ORDS_SCHEMA_AUTH_CREDENTIALS).toString('base64')}`; -const ADB_SCHEMA_ENDPOINT = `${ADB_ORDS_URL}${SCHEMA_NAME.toLowerCase()}/`; +const { + BD_CONNECT_STRING, + SCHEMA_NAME, + SCHEMA_PASSWORD, +} = process.env; /** - * Seeding script populates the schema objects with randomly generated data. + * Returns a required environment variable and throws when missing. + * @param {string | undefined} value the variable value. + * @param {string} variableName the variable name. + * @returns {string} the non-empty variable value. + */ +function getRequiredEnvVar(value, variableName) { + if (!value || value.trim() === '') { + throw new Error(`Missing required environment variable: ${variableName}`); + } + return value; +} + +/** + * Seeding script populates the schema objects with sample data using direct SQL. */ async function seed() { - await populateObjects(ADB_SCHEMA_ENDPOINT, BASIC_SCHEMA_AUTH); - // eslint-disable-next-line no-console - console.log('Disabling autoREST functionality...'); - await autoRESTDisableObjects(SCHEMA_NAME, ADB_SCHEMA_ENDPOINT, BASIC_SCHEMA_AUTH); + const connectString = getRequiredEnvVar(BD_CONNECT_STRING, 'BD_CONNECT_STRING'); + const schemaName = getRequiredEnvVar(SCHEMA_NAME, 'SCHEMA_NAME'); + const schemaPassword = getRequiredEnvVar(SCHEMA_PASSWORD, 'SCHEMA_PASSWORD'); + + let schemaConnection; + try { + schemaConnection = await getConnection({ + user: schemaName, + password: schemaPassword, + connectString, + }); + await populateObjects(schemaConnection); + } finally { + await closeConnection(schemaConnection); + } } -seed(); +seed().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; +}); diff --git a/templates/ords-remix-jwt-sample/ords/seedScripts/batchload.js b/templates/ords-remix-jwt-sample/ords/seedScripts/batchload.js index f61d638..8188116 100644 --- a/templates/ords-remix-jwt-sample/ords/seedScripts/batchload.js +++ b/templates/ords-remix-jwt-sample/ords/seedScripts/batchload.js @@ -4,69 +4,359 @@ ** All rights reserved ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ */ -import * as fs from 'fs'; -import { postBatchRequest } from '../utils/ORDSRequests.js'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; + /* - * Disabling the no console rule for this specific route since it allows us to communicate - * the result of the batchload requests to the user. + * Disabling no-console because this script intentionally prints + * batch-style progress output for users. */ /* eslint-disable no-console */ + +const DATA_DIRECTORY = path.resolve('ords/data'); +const CSV_DELIMITER = ','; +const DOUBLE_QUOTE = '"'; +const ORA_TABLE_OR_VIEW_DOES_NOT_EXIST = 942; +const TABLES_TO_TRUNCATE = [ + 'TICKET', + 'LIKED_EVENT', + 'LIKED_VENUE', + 'LIKED_MUSIC_GENRES', + 'LIKED_ARTIST', + 'ARTIST_CLASSIFICATIONS', + 'EVENTS', + 'VENUES', + 'CITIES', + 'EVENT_STATUS', + 'MUSIC_GENRES', + 'ARTISTS', +]; + /** - * Creates all the Admin User Modules. - * Uses the schema credentials. - * @param {string} endpoint the ADB-S ords endpoint. - * @param {string} basicAuth the authentication string for the models owner. + * Parses a CSV line, supporting quoted values. + * @param {string} line the line to parse. + * @returns {string[]} parsed CSV fields. */ -async function populateObjects(endpoint, basicAuth) { - const citiesFileStream = fs.createReadStream('ords/data/CITIES.csv'); - const citiesBatchResult = await postBatchRequest( - `${endpoint}cities/batchload?batchRows=50&dateFormat=DD-MM-YYYY`, - citiesFileStream, - basicAuth, +function parseCsvLine(line) { + const values = []; + let currentValue = ''; + let inQuotes = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const nextChar = line[index + 1]; + + if (char === DOUBLE_QUOTE) { + if (inQuotes && nextChar === DOUBLE_QUOTE) { + currentValue += DOUBLE_QUOTE; + index += 1; + } else { + inQuotes = !inQuotes; + } + } else if (char === CSV_DELIMITER && !inQuotes) { + values.push(currentValue.trim()); + currentValue = ''; + } else { + currentValue += char; + } + } + + values.push(currentValue.trim()); + return values; +} + +/** + * Loads and parses a CSV file from ords/data. + * @param {string} fileName the CSV file name. + * @returns {Promise>>} parsed rows. + */ +async function loadCsvRows(fileName) { + const filePath = path.join(DATA_DIRECTORY, fileName); + const fileContents = await fs.readFile(filePath, 'utf8'); + const lines = fileContents + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); + + if (lines.length === 0) { + return []; + } + + const headers = parseCsvLine(lines[0]).map((header) => header.trim().toUpperCase()); + return lines.slice(1).map((line) => { + const fields = parseCsvLine(line); + const row = {}; + headers.forEach((header, index) => { + row[header] = (fields[index] || '').trim(); + }); + return row; + }); +} + +/** + * Safely converts a CSV field to a number. + * @param {string} value the raw CSV value. + * @param {string} fieldName the field name for error context. + * @returns {number} parsed number value. + */ +function toNumber(value, fieldName) { + const parsedValue = Number.parseInt(value, 10); + if (Number.isNaN(parsedValue)) { + throw new Error(`Invalid numeric value for ${fieldName}: "${value}"`); + } + return parsedValue; +} + +/** + * Prints ORDS batchload-like output block. + * @param {number} rowsProcessed number of rows processed. + * @param {number} rowsInError number of rows in error. + * @param {number | null} committedRows rows committed in final batch. + */ +function printBatchOutput(rowsProcessed, rowsInError, committedRows) { + console.log(`#INFO Number of rows processed: ${rowsProcessed}`); + console.log(`#INFO Number of rows in error: ${rowsInError}`); + if (committedRows === null) { + console.log('#INFO No rows committed'); + } else { + console.log(`#INFO Last row processed in final committed batch: ${committedRows}`); + } + if (rowsInError === 0) { + console.log('SUCCESS: Processed without errors'); + } else { + console.log('WARNING: Processed with errors'); + } + console.log(''); +} + +/** + * Executes insertMany for one table and prints optional batch output. + * @param {import('oracledb').Connection} connection the schema connection. + * @param {string} statement the INSERT statement. + * @param {Array} binds bind rows. + * @param {{showBatchOutput: boolean, committedRows: number | null, commitAfter: boolean}} options + * execution options. + */ +async function runInsertBatch(connection, statement, binds, options) { + const rowsProcessed = binds.length; + let rowsInError = 0; + + if (rowsProcessed > 0) { + const result = await connection.executeMany(statement, binds, { batchErrors: true }); + rowsInError = result.batchErrors?.length || 0; + } + + if (options.commitAfter) { + await connection.commit(); + } + + if (options.showBatchOutput) { + printBatchOutput(rowsProcessed, rowsInError, options.committedRows); + } +} + +/** + * Truncates known seed tables in dependency-safe order. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function truncateSeedTables(connection) { + await TABLES_TO_TRUNCATE.reduce( + async (previousTruncate, tableName) => { + await previousTruncate; + try { + await connection.execute(`TRUNCATE TABLE ${tableName}`); + } catch (error) { + if (error.errorNum !== ORA_TABLE_OR_VIEW_DOES_NOT_EXIST) { + throw error; + } + } + }, + Promise.resolve(), ); - console.log(citiesBatchResult); - const venuesFileStream = fs.createReadStream('ords/data/VENUES.csv'); - const venuesBatchResult = await postBatchRequest( - `${endpoint}venues/batchload?batchRows=50&dateFormat=DD-MM-YYYY`, - venuesFileStream, - basicAuth, +} + +/** + * Inserts and logs cities batch. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function insertCities(connection) { + const cityRows = await loadCsvRows('CITIES.csv'); + const binds = cityRows.map((row) => ({ + NAME: row.NAME, + DESCRIPTION: row.DESCRIPTION, + })); + await runInsertBatch( + connection, + `INSERT INTO CITIES (NAME, DESCRIPTION) + VALUES (:NAME, :DESCRIPTION)`, + binds, + { + showBatchOutput: true, + committedRows: null, + commitAfter: false, + }, + ); +} + +/** + * Inserts and logs venues batch. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function insertVenues(connection) { + const venueRows = await loadCsvRows('VENUES.csv'); + const binds = venueRows.map((row) => ({ + NAME: row.NAME, + LOCATION: row.LOCATION, + CITY_ID: toNumber(row.CITY_ID, 'VENUES.CITY_ID'), + })); + await runInsertBatch( + connection, + `INSERT INTO VENUES (NAME, LOCATION, CITY_ID) + VALUES (:NAME, :LOCATION, :CITY_ID)`, + binds, + { + showBatchOutput: true, + committedRows: null, + commitAfter: false, + }, ); - console.log(venuesBatchResult); - const artistsFileStream = fs.createReadStream('ords/data/ARTISTS.csv'); - const artistsBatchResult = await postBatchRequest( - `${endpoint}artists/batchload?batchRows=50&dateFormat=YYYY-MM-DD`, - artistsFileStream, - basicAuth, +} + +/** + * Inserts and logs artists batch. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function insertArtists(connection) { + const artistRows = await loadCsvRows('ARTISTS.csv'); + const binds = artistRows.map((row) => ({ + NAME: row.NAME, + DESCRIPTION: row.DESCRIPTION, + BIO: row.BIO, + })); + await runInsertBatch( + connection, + `INSERT INTO ARTISTS (NAME, DESCRIPTION, BIO) + VALUES (:NAME, :DESCRIPTION, :BIO)`, + binds, + { + showBatchOutput: true, + committedRows: null, + commitAfter: false, + }, ); - console.log(artistsBatchResult); - const eventStatusFileStream = fs.createReadStream('ords/data/EVENT_STATUS.csv'); - const eventStatusBatchResult = await postBatchRequest( - `${endpoint}event_status/batchload?batchRows=50&dateFormat=YYYY-MM-DD`, - eventStatusFileStream, - basicAuth, +} + +/** + * Inserts and logs event status batch. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function insertEventStatus(connection) { + const eventStatusRows = await loadCsvRows('EVENT_STATUS.csv'); + const binds = eventStatusRows.map((row) => ({ + EVENT_STATUS_ID: toNumber(row.EVENT_STATUS_ID, 'EVENT_STATUS.EVENT_STATUS_ID'), + EVENT_STATUS_NAME: row.EVENT_STATUS_NAME, + EVENT_STATUS_DESCRIPTION: row.EVENT_STATUS_DESCRIPTION, + })); + await runInsertBatch( + connection, + `INSERT INTO EVENT_STATUS (EVENT_STATUS_ID, EVENT_STATUS_NAME, EVENT_STATUS_DESCRIPTION) + VALUES (:EVENT_STATUS_ID, :EVENT_STATUS_NAME, :EVENT_STATUS_DESCRIPTION)`, + binds, + { + showBatchOutput: true, + committedRows: binds.length, + commitAfter: true, + }, ); - console.log(eventStatusBatchResult); - const eventsFileStream = fs.createReadStream('ords/data/EVENTS.csv'); - const eventsBatchResult = await postBatchRequest( - `${endpoint}events/batchload?batchRows=50&dateFormat=YYYY-MM-DD`, - eventsFileStream, - basicAuth, +} + +/** + * Inserts and logs events batch. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function insertEvents(connection) { + const eventRows = await loadCsvRows('EVENTS.csv'); + const binds = eventRows.map((row) => ({ + EVENT_DATE: row.EVENT_DATE, + ARTIST_ID: toNumber(row.ARTIST_ID, 'EVENTS.ARTIST_ID'), + VENUE_ID: toNumber(row.VENUE_ID, 'EVENTS.VENUE_ID'), + EVENT_STATUS_ID: toNumber(row.EVENT_STATUS_ID, 'EVENTS.EVENT_STATUS_ID'), + EVENT_DETAILS: row.EVENT_DETAILS, + })); + await runInsertBatch( + connection, + `INSERT INTO EVENTS (EVENT_DATE, ARTIST_ID, VENUE_ID, EVENT_STATUS_ID, EVENT_DETAILS) + VALUES (TO_DATE(:EVENT_DATE, 'YYYY-MM-DD'), :ARTIST_ID, :VENUE_ID, :EVENT_STATUS_ID, :EVENT_DETAILS)`, + binds, + { + showBatchOutput: true, + committedRows: binds.length, + commitAfter: true, + }, ); - console.log(eventsBatchResult); - const musicGenresFileStream = fs.createReadStream('ords/data/MUSIC_GENRES.csv'); - const musicGenresBatchResult = await postBatchRequest( - `${endpoint}music_genres/batchload?batchRows=50&dateFormat=YYYY-MM-DD`, - musicGenresFileStream, - basicAuth, +} + +/** + * Inserts music genres without ORDS-style batch output. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function insertMusicGenres(connection) { + const musicGenreRows = await loadCsvRows('MUSIC_GENRES.csv'); + const binds = musicGenreRows.map((row) => ({ + NAME: row.NAME, + DESCRIPTION: row.DESCRIPTION, + })); + await runInsertBatch( + connection, + `INSERT INTO MUSIC_GENRES (NAME, DESCRIPTION) + VALUES (:NAME, :DESCRIPTION)`, + binds, + { + showBatchOutput: false, + committedRows: null, + commitAfter: false, + }, ); - console.log(musicGenresBatchResult); - const artistClassificationsFileStream = fs.createReadStream('ords/data/ARTIST_CLASSIFICATIONS.csv'); - const artistClassificationsBatchResult = await postBatchRequest( - `${endpoint}artist_classifications/batchload?batchRows=50&dateFormat=YYYY-MM-DD`, - artistClassificationsFileStream, - basicAuth, +} + +/** + * Inserts artist classifications without ORDS-style batch output. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function insertArtistClassifications(connection) { + const classificationRows = await loadCsvRows('ARTIST_CLASSIFICATIONS.csv'); + const binds = classificationRows.map((row) => ({ + ARTIST_ID: toNumber(row.ARTIST_ID, 'ARTIST_CLASSIFICATIONS.ARTIST_ID'), + MUSIC_GENRE_ID: toNumber(row.MUSIC_GENRE_ID, 'ARTIST_CLASSIFICATIONS.MUSIC_GENRE_ID'), + })); + await runInsertBatch( + connection, + `INSERT INTO ARTIST_CLASSIFICATIONS (ARTIST_ID, MUSIC_GENRE_ID) + VALUES (:ARTIST_ID, :MUSIC_GENRE_ID)`, + binds, + { + showBatchOutput: false, + committedRows: null, + commitAfter: false, + }, ); - console.log(artistClassificationsBatchResult); +} + +/** + * Populates schema objects with sample data using direct SQL. + * @param {import('oracledb').Connection} connection the schema connection. + */ +async function populateObjects(connection) { + await truncateSeedTables(connection); + await insertCities(connection); + await insertVenues(connection); + await insertArtists(connection); + await insertEventStatus(connection); + await insertEvents(connection); + + await insertMusicGenres(connection); + await insertArtistClassifications(connection); + await connection.commit(); } export default populateObjects; diff --git a/templates/ords-remix-jwt-sample/ords/utils/dropUser.js b/templates/ords-remix-jwt-sample/ords/utils/dropUser.js index 45f2c5f..a98f706 100644 --- a/templates/ords-remix-jwt-sample/ords/utils/dropUser.js +++ b/templates/ords-remix-jwt-sample/ords/utils/dropUser.js @@ -4,49 +4,77 @@ ** All rights reserved ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ */ -import { - postRequest, - printResponse, -} from './ORDSRequests.js'; +import { executeWithLogging } from './sqlExecutionLogger.js'; + +const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_$#]*$/; +const ORA_INVALID_IDENTIFIER = 904; +const ORA_TABLE_OR_VIEW_DOES_NOT_EXIST = 942; +const ORA_INSUFFICIENT_PRIVILEGES = 1031; +const ORA_USER_DOES_NOT_EXIST = 1918; +const IGNORABLE_SESSION_KILL_ERRORS = [ + ORA_INVALID_IDENTIFIER, + ORA_TABLE_OR_VIEW_DOES_NOT_EXIST, + ORA_INSUFFICIENT_PRIVILEGES, +]; + +/** + * Validates and normalizes an Oracle identifier. + * @param {string} identifier the raw identifier. + * @returns {string} uppercase Oracle-safe identifier. + */ +function normalizeIdentifier(identifier) { + if (!identifier || !IDENTIFIER_PATTERN.test(identifier)) { + throw new Error('Invalid SCHEMA_NAME. Use only Oracle identifier-safe characters.'); + } + return identifier.toUpperCase(); +} /** - * Drops a user and all the objects associated to it. Useful for a cleanup script. - * Uses the admin credentials. - * @param {string} schemaName the name of the schema to create. - * @param {string} endpoint the ADB-S admin endpoint. - * @param {string} basicAuth the authentication string for the admin user. + * Kills active sessions for a schema, when permitted. + * @param {import('oracledb').Connection} connection the admin connection. + * @param {string} schemaName uppercase schema name. */ -async function dropUser(schemaName, endpoint, basicAuth) { - const dropRESTForSchemaStatement = `begin - ords_admin.drop_rest_for_schema( - p_schema => '${schemaName}' - ); - commit; - end; - / - `; - const disableSchemaStatement = `begin - ords_admin.enable_schema( - p_schema => '${schemaName}', - p_enabled => false); - commit; - end; - / - `; - const dropSessionStatement = `BEGIN - FOR r IN (select sid,serial#, inst_id from gv$session where username='${schemaName}') - LOOP - EXECUTE IMMEDIATE - 'alter system kill session ''' || r.sid || ',' || r.serial# || ',@' || r.inst_id || ''' immediate'; - END LOOP; - END; - / - `; - const dropUserStatement = `DROP USER ${schemaName} CASCADE;`; - const dropUserStatements = [disableSchemaStatement, dropRESTForSchemaStatement, - dropSessionStatement, dropUserStatement].join(' '); - const dropTestResponse = await postRequest(endpoint, dropUserStatements, basicAuth); - printResponse(dropTestResponse); +async function killSchemaSessions(connection, schemaName) { + try { + await executeWithLogging( + connection, + `BEGIN + FOR schema_session IN ( + SELECT sid, serial#, inst_id + FROM gv$session + WHERE username = :schemaName + ) LOOP + EXECUTE IMMEDIATE + 'alter system kill session ''' || + schema_session.sid || ',' || schema_session.serial# || ',@' || + schema_session.inst_id || ''' immediate'; + END LOOP; + END;`, + { schemaName }, + ); + } catch (error) { + if (!IGNORABLE_SESSION_KILL_ERRORS.includes(error.errorNum)) { + throw error; + } + } +} + +/** + * Drops a schema and all associated objects. + * @param {import('oracledb').Connection} connection the admin connection. + * @param {string} schemaName the schema to drop. + */ +async function dropUser(connection, schemaName) { + const normalizedSchemaName = normalizeIdentifier(schemaName); + await killSchemaSessions(connection, normalizedSchemaName); + + try { + await executeWithLogging(connection, `DROP USER ${normalizedSchemaName} CASCADE`); + } catch (error) { + if (error.errorNum !== ORA_USER_DOES_NOT_EXIST) { + throw error; + } + } } export default dropUser; diff --git a/templates/ords-remix-jwt-sample/ords/utils/enviroment.js b/templates/ords-remix-jwt-sample/ords/utils/enviroment.js index 271f33b..cf185d2 100644 --- a/templates/ords-remix-jwt-sample/ords/utils/enviroment.js +++ b/templates/ords-remix-jwt-sample/ords/utils/enviroment.js @@ -17,9 +17,9 @@ import { const MIN_PASSWORD_LENGTH = 8; /** * @typedef {{ - ADB_ORDS_URL: string; - ADB_ADMIN_USERNAME: string; - ADB_ADMIN_PASSWORD: string; + BD_ORDS_URL: string; + BD_ADMIN_USERNAME: string; + BD_ADMIN_PASSWORD: string; SCHEMA_NAME: string; SCHEMA_PASSWORD: string | Promise;}} config */ @@ -35,9 +35,9 @@ async function setConfig() { }); const useCli = await rl.question('Do you want to set your config with the CLI? (y/n): ') || 'n'; if (useCli.toLocaleLowerCase() === 'y') { - config.ADB_ORDS_URL = await rl.question('ORDS URL: ') || 'example.com:8080/ords/'; - config.ADB_ADMIN_USERNAME = await rl.question('Admin username: ') || 'ADMIN'; - config.ADB_ADMIN_PASSWORD = await rl.question('Admin password: ') || 'oracle'; + config.BD_ORDS_URL = await rl.question('ORDS URL: ') || 'example.com:8080/ords/'; + config.BD_ADMIN_USERNAME = await rl.question('Admin username: ') || 'ADMIN'; + config.BD_ADMIN_PASSWORD = await rl.question('Admin password: ') || 'oracle'; config.SCHEMA_NAME = await rl.question('Schema Name: ') || 'HR'; config.SCHEMA_PASSWORD = await rl.question('Schema Password: ') || 'oracle'; while (config.SCHEMA_PASSWORD.length < MIN_PASSWORD_LENGTH) { diff --git a/templates/ords-remix-jwt-sample/ords/utils/oracleConnection.js b/templates/ords-remix-jwt-sample/ords/utils/oracleConnection.js new file mode 100644 index 0000000..761ed33 --- /dev/null +++ b/templates/ords-remix-jwt-sample/ords/utils/oracleConnection.js @@ -0,0 +1,67 @@ +/* +** +** Copyright (c) 2024, Oracle and/or its affiliates. +** All rights reserved +** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +*/ +import oracledb from 'oracledb'; + +let oracleClientInitialized = false; + +/** + * Initializes Oracle Thick mode once for the process. + */ +function initializeOracleClient() { + if (oracleClientInitialized) { + return; + } + + const libDir = process.env.ORACLE_CLIENT_LIB_DIR; + if(!libDir) + throw new Error("Missing required setting: ORACLE_CLIENT_LIB_DIR"); + else{ + oracledb.initOracleClient({ libDir }); + oracleClientInitialized = true; + } +} + +/** + * Returns a required setting, throwing an error if missing. + * @param {string | undefined} value the setting value. + * @param {string} settingName the setting name for error messages. + * @returns {string} the non-empty setting value. + */ +function getRequiredSetting(value, settingName) { + if (!value || value.trim() === '') { + throw new Error(`Missing required setting: ${settingName}`); + } + return value; +} + +/** + * Creates a database connection using Oracle Thick mode. + * @param {{ + * user: string | undefined, + * password: string | undefined, + * connectString: string | undefined, + * }} config the connection configuration. + * @returns {Promise} the database connection. + */ +export async function getConnection(config) { + initializeOracleClient(); + return oracledb.getConnection({ + user: getRequiredSetting(config.user, 'user'), + password: getRequiredSetting(config.password, 'password'), + connectString: getRequiredSetting(config.connectString, 'BD_CONNECT_STRING'), + }); +} + +/** + * Closes a database connection when it exists. + * @param {oracledb.Connection | undefined} connection the connection to close. + */ +export async function closeConnection(connection) { + if (connection) { + await connection.close(); + } +} diff --git a/templates/ords-remix-jwt-sample/ords/utils/sqlExecutionLogger.js b/templates/ords-remix-jwt-sample/ords/utils/sqlExecutionLogger.js new file mode 100644 index 0000000..e62841a --- /dev/null +++ b/templates/ords-remix-jwt-sample/ords/utils/sqlExecutionLogger.js @@ -0,0 +1,120 @@ +/* +** +** Copyright (c) 2024, Oracle and/or its affiliates. +** All rights reserved +** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +*/ +/* + * Disabling no-console in this utility because it is dedicated to + * operational script logging. +*/ +/* eslint-disable no-console */ + +const MAX_STATEMENT_PREVIEW_LENGTH = 260; +const PREVIEW_ELLIPSIS = '...'; +const DML_VERBS_WITH_ROW_COUNT = ['INSERT', 'UPDATE', 'DELETE']; +let statementCounter = 0; + +/** + * Formats SQL into a single line for readable logs. + * @param {string} statement the SQL statement. + * @returns {string} normalized statement. + */ +function normalizeStatement(statement) { + return statement.replace(/\s+/g, ' ').trim(); +} + +/** + * Returns the first SQL verb from a statement. + * @param {string} statement the SQL statement. + * @returns {string} uppercase SQL verb or empty string. + */ +function getStatementVerb(statement) { + const normalizedStatement = normalizeStatement(statement); + const [verb = ''] = normalizedStatement.split(' '); + return verb.toUpperCase(); +} + +/** + * Creates a statement preview that stays compact in terminal output. + * @param {string} statement the SQL statement. + * @returns {string} statement preview. + */ +function getStatementPreview(statement) { + const normalizedStatement = normalizeStatement(statement); + if (normalizedStatement.length <= MAX_STATEMENT_PREVIEW_LENGTH) { + return normalizedStatement; + } + + const previewLength = MAX_STATEMENT_PREVIEW_LENGTH - PREVIEW_ELLIPSIS.length; + return `${normalizedStatement.slice(0, previewLength)}${PREVIEW_ELLIPSIS}`; +} + +/** + * Formats rowsAffected for logging. + * @param {import('oracledb').Result} result the execution result. + * @returns {string} rows affected display. + */ +function getRowsAffectedDisplay(result) { + if (typeof result.rowsAffected === 'number') { + return `${result.rowsAffected}`; + } + return 'N/A'; +} + +/** + * Determines if rows affected should be displayed for the statement. + * @param {string} statement the SQL statement. + * @returns {boolean} true when statement is INSERT/UPDATE/DELETE. + */ +function shouldDisplayRowsAffected(statement) { + return DML_VERBS_WITH_ROW_COUNT.includes(getStatementVerb(statement)); +} + +/** + * Prints a standard SQL execution log line set. + * @param {string} statement the SQL statement. + * @param {import('oracledb').Result} result the execution result. + */ +function printExecutionResult(statement, result) { + statementCounter += 1; + console.log(`id : ${statementCounter}`); + console.log(`statement : ${getStatementPreview(statement)}`); + if (shouldDisplayRowsAffected(statement)) { + console.log(`rows affected: ${getRowsAffectedDisplay(result)}`); + } + console.log('response: Statement executed successfully.'); +} + +/** + * Executes SQL and logs statement text and rows affected. + * @param {import('oracledb').Connection} connection the database connection. + * @param {string} statement the SQL statement. + * @param {import('oracledb').BindParameters} [binds] optional binds. + * @param {import('oracledb').ExecuteOptions} [options] optional execute options. + * @returns {Promise>} execution result. + */ +export async function executeWithLogging(connection, statement, binds = {}, options = {}) { + const result = await connection.execute(statement, binds, options); + printExecutionResult(statement, result); + return result; +} + +/** + * Executes SQL in bulk and logs statement text and rows affected. + * @param {import('oracledb').Connection} connection the database connection. + * @param {string} statement the SQL statement. + * @param {Array} bindsArray bind rows. + * @param {import('oracledb').ExecuteManyOptions} [options] optional executeMany options. + * @returns {Promise>} execution result. + */ +export async function executeManyWithLogging( + connection, + statement, + bindsArray, + options = {}, +) { + const result = await connection.executeMany(statement, bindsArray, options); + printExecutionResult(statement, result); + return result; +} diff --git a/templates/ords-remix-jwt-sample/ords_config_service/apispec.json b/templates/ords-remix-jwt-sample/ords_config_service/apispec.json new file mode 100644 index 0000000..ab8f54f --- /dev/null +++ b/templates/ords-remix-jwt-sample/ords_config_service/apispec.json @@ -0,0 +1,1795 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Concert App API Spec", + "version": "1.0.0" + }, + "paths": { + "/euser/v1/artists": { + "get": { + "operationId": "get_concert_app_euser_v1_artists", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "artists/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT\n A.ARTIST_ID,\n A.NAME,\n A.DESCRIPTION,\n LISTAGG(MG.NAME, ', ') WITHIN GROUP(\n ORDER BY\n MG.NAME\n ) AS MUSIC_GENRES\n FROM\n ARTISTS A\n LEFT JOIN ARTIST_CLASSIFICATIONS AA ON A.ARTIST_ID = AA.ARTIST_ID\n LEFT JOIN MUSIC_GENRES MG ON AA.MUSIC_GENRE_ID = MG.MUSIC_GENRE_ID\n GROUP BY\n A.ARTIST_ID,\n A.NAME,\n A.DESCRIPTION\n ORDER BY\n A.NAME", + "parameters": [] + } + } + }, + "/euser/v1/artist/{id}": { + "get": { + "operationId": "get_concert_app_euser_v1_artist_id", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "artist/:id", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT\n A.ARTIST_ID,\n A.NAME,\n A.DESCRIPTION,\n A.BIO,\n LISTAGG(MG.NAME, ', ') WITHIN GROUP( \n ORDER BY\n MG.NAME\n ) AS MUSIC_GENRES\n FROM\n ARTISTS A\n LEFT JOIN ARTIST_CLASSIFICATIONS AA ON A.ARTIST_ID = AA.ARTIST_ID\n LEFT JOIN MUSIC_GENRES MG ON AA.MUSIC_GENRE_ID = MG.MUSIC_GENRE_ID\n WHERE A.ARTIST_ID = :id\n GROUP BY\n A.ARTIST_ID,\n A.NAME,\n A.DESCRIPTION,\n A.BIO\n ORDER BY\n A.NAME", + "parameters": [ + { + "name": "id", + "bindVariable": "id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/artists/{artist_name}": { + "get": { + "operationId": "get_concert_app_euser_v1_artists_artist_name", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "artist_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "artists/:artist_name", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM ARTISTS \n WHERE NAME LIKE '%' || :artist_name || '%'", + "parameters": [ + { + "name": "artist_name", + "bindVariable": "artist_name", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/venues": { + "get": { + "operationId": "get_concert_app_euser_v1_venues", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "venues/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM VENUES ORDER BY NAME DESC", + "parameters": [] + } + } + }, + "/euser/v1/venues/{venue_name}": { + "get": { + "operationId": "get_concert_app_euser_v1_venues_venue_name", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "venue_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "venues/:venue_name", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM VENUES\n WHERE NAME LIKE '%' || :venue_name || '%'", + "parameters": [ + { + "name": "venue_name", + "bindVariable": "venue_name", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/cities": { + "get": { + "operationId": "get_concert_app_euser_v1_cities", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "cities/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT C.CITY_ID, C.NAME, C.DESCRIPTION, COUNT(E.EVENT_ID) AS EVENT_COUNT\n FROM CITIES C\n LEFT JOIN VENUES V ON C.CITY_ID = V.CITY_ID\n LEFT JOIN EVENTS E ON V.VENUE_ID = E.VENUE_ID\n GROUP BY C.CITY_ID, C.NAME, C.DESCRIPTION\n ORDER BY EVENT_COUNT DESC", + "parameters": [] + } + } + }, + "/euser/v1/venues_by_city/{city_name}": { + "get": { + "operationId": "get_concert_app_euser_v1_venues_by_city_city_name", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "city_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "venues_by_city/:city_name", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT V.* \n FROM VENUES V JOIN CITIES C \n ON V.CITY_ID = C.CITY_ID\n WHERE C.CITY_NAME = :CITY_NAME\n ", + "parameters": [ + { + "name": "city_name", + "bindVariable": "city_name", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/venue/{venue_id}": { + "get": { + "operationId": "get_concert_app_euser_v1_venue_venue_id", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "venue_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "venue/:venue_id", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM VENUES WHERE VENUE_ID = :VENUE_ID", + "parameters": [ + { + "name": "venue_id", + "bindVariable": "venue_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/events": { + "get": { + "operationId": "get_concert_app_euser_v1_events", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "events/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM EVENTS", + "parameters": [] + } + } + }, + "/euser/v1/events/{event_name}": { + "get": { + "operationId": "get_concert_app_euser_v1_events_event_name", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "event_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "events/:event_name", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM EVENTS_VIEW \n WHERE ARTIST_NAME LIKE '%' || :event_name || '%'\n ORDER BY EVENT_DATE ASC", + "parameters": [ + { + "name": "event_name", + "bindVariable": "event_name", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/event/{event_id}": { + "get": { + "operationId": "get_concert_app_euser_v1_event_event_id", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "event_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "event/:event_id", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM EVENTS_VIEW WHERE EVENT_ID = :event_id", + "parameters": [ + { + "name": "event_id", + "bindVariable": "event_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/landing_page_global_stats": { + "get": { + "operationId": "get_concert_app_euser_v1_landing_page_global_stats", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "landing_page_global_stats/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM BANNER_VIEW", + "parameters": [] + } + } + }, + "/euser/v1/eventsHome": { + "get": { + "operationId": "get_concert_app_euser_v1_eventshome", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "eventsHome/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM EVENTS_VIEW ORDER BY EVENT_DATE ASC", + "parameters": [] + } + } + }, + "/euser/v1/artistEvents/{id}": { + "get": { + "operationId": "get_concert_app_euser_v1_artistevents_id", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "artistEvents/:id", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM EVENTS_VIEW WHERE ARTIST_ID = :id ORDER BY EVENT_DATE ASC", + "parameters": [ + { + "name": "id", + "bindVariable": "id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/cityEvents/{cityName}": { + "get": { + "operationId": "get_concert_app_euser_v1_cityevents_cityname", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [ + { + "name": "cityName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "cityEvents/:cityName", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM EVENTS_VIEW WHERE CITY_NAME=:cityName ORDER BY EVENT_DATE ASC", + "parameters": [ + { + "name": "cityName", + "bindVariable": "cityName", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/euser/v1/eventStatus": { + "get": { + "operationId": "get_concert_app_euser_v1_eventstatus", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "eventStatus/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM EVENT_STATUS", + "parameters": [] + } + } + }, + "/euser/v1/musicGenres": { + "get": { + "operationId": "get_concert_app_euser_v1_musicgenres", + "tags": [ + "concert_app.euser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [], + "x-dbtools-operation": { + "moduleName": "concert_app.euser.v1", + "pattern": "musicGenres/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT * FROM MUSIC_GENRES", + "parameters": [] + } + } + }, + "/authuser/v1/events": { + "get": { + "operationId": "get_concert_app_authuser_v1_events", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "events/", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT \n a.name AS ARTIST_NAME,\n e.event_id AS EVENT_ID,\n e.event_date AS EVENT_DATE,\n e.event_details AS EVENT_DETAILS,\n es.event_status_name as EVENT_STATUS_NAME,\n es.event_status_id AS EVENT_STATUS_ID,\n v.venue_id AS VENUE_ID,\n v.name AS VENUE_NAME\n FROM \n events e\n INNER JOIN artists a ON e.artist_id = a.artist_id\n INNER JOIN event_status es ON e.event_status_id = es.event_status_id\n INNER JOIN venues v ON e.venue_id = v.venue_id\n ORDER BY e.event_date DESC", + "parameters": [] + } + } + }, + "/authuser/v1/event/{event_id}": { + "get": { + "operationId": "get_concert_app_authuser_v1_event_event_id", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [ + { + "name": "event_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "event/:event_id", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT \n a.name AS ARTIST_NAME,\n e.event_id AS EVENT_ID,\n e.event_date AS EVENT_DATE,\n e.event_details AS EVENT_DETAILS,\n es.event_status_name as EVENT_STATUS_NAME,\n es.event_status_id AS EVENT_STATUS_ID,\n v.venue_id AS VENUE_ID,\n v.name AS VENUE_NAME\n FROM \n events e\n INNER JOIN artists a ON e.artist_id = a.artist_id\n INNER JOIN event_status es ON e.event_status_id = es.event_status_id\n INNER JOIN venues v ON e.venue_id = v.venue_id\n WHERE e.event_id = :event_id", + "parameters": [ + { + "name": "event_id", + "bindVariable": "event_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/authuser/v1/liked_artists/{user_id}": { + "get": { + "operationId": "get_concert_app_authuser_v1_liked_artists_user_id", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_artists/:user_id", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT A.* FROM ARTISTS A \n JOIN LIKED_ARTIST L_A \n ON A.ARTIST_ID = L_A.ARTIST_ID\n WHERE L_A.USER_ID = :user_id", + "parameters": [ + { + "name": "user_id", + "bindVariable": "user_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/authuser/v1/liked_artist/{user_id}/{artist_id}": { + "get": { + "operationId": "get_concert_app_authuser_v1_liked_artist_user_id_artist_id", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "artist_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_artist/:user_id/:artist_id", + "method": "GET", + "sourceType": "json/item", + "source": "SELECT COUNT(1) as likedArtist FROM LIKED_ARTIST \n WHERE USER_ID = :user_id AND ARTIST_ID = :artist_id", + "parameters": [ + { + "name": "user_id", + "bindVariable": "user_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + }, + { + "name": "artist_id", + "bindVariable": "artist_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/authuser/v1/liked_venues/{user_id}": { + "get": { + "operationId": "get_concert_app_authuser_v1_liked_venues_user_id", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_venues/:user_id", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT V.* FROM VENUE V \n JOIN LIKED_VENUE L_V \n ON V.ID = L_V.VENUE_ID\n WHERE L_V.USER_ID = :user_id", + "parameters": [ + { + "name": "user_id", + "bindVariable": "user_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/authuser/v1/liked_venue/{user_id}/{venue_id}": { + "get": { + "operationId": "get_concert_app_authuser_v1_liked_venue_user_id_venue_id", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "venue_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_venue/:user_id/:venue_id", + "method": "GET", + "sourceType": "json/item", + "source": "SELECT COUNT(1) as likedVenue FROM LIKED_VENUE \n WHERE USER_ID = :user_id AND VENUE_ID = :venue_id", + "parameters": [ + { + "name": "user_id", + "bindVariable": "user_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + }, + { + "name": "venue_id", + "bindVariable": "venue_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/authuser/v1/liked_events/{user_id}": { + "get": { + "operationId": "get_concert_app_authuser_v1_liked_events_user_id", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_events/:user_id", + "method": "GET", + "sourceType": "json/collection", + "source": "SELECT E.* FROM EVENTS E \n JOIN LIKED_EVENT L_E \n ON E.EVENT_ID = L_E.EVENT_ID\n WHERE L_E.USER_ID = :user_id", + "parameters": [ + { + "name": "user_id", + "bindVariable": "user_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/authuser/v1/liked_event/{user_id}/{event_id}": { + "get": { + "operationId": "get_concert_app_authuser_v1_liked_event_user_id_event_id", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "event_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_event/:user_id/:event_id", + "method": "GET", + "sourceType": "json/item", + "source": "SELECT COUNT(1) as likedEvent FROM LIKED_EVENT \n WHERE USER_ID = :user_id AND EVENT_ID = :event_id", + "parameters": [ + { + "name": "user_id", + "bindVariable": "user_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + }, + { + "name": "event_id", + "bindVariable": "event_id", + "sourceType": "URI", + "parameterType": "STRING", + "accessMethod": "IN", + "description": "" + } + ] + } + } + }, + "/authuser/v1/liked_artist": { + "post": { + "operationId": "post_concert_app_authuser_v1_liked_artist", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_artist", + "method": "POST", + "sourceType": "plsql/block", + "source": "BEGIN\n INSERT INTO LIKED_ARTIST(ARTIST_ID, USER_ID)\n VALUES (:ARTIST_ID, :USER_ID);\n :status_code := 201;\n :pv_result := 'ARTIST LIKED SUCCESSFULLY';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code := 400;\n :pv_result := 'UNABLE TO LIKE ARTIST';\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "delete": { + "operationId": "delete_concert_app_authuser_v1_liked_artist", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_artist", + "method": "DELETE", + "sourceType": "plsql/block", + "source": "BEGIN\n DELETE FROM LIKED_ARTIST\n WHERE ARTIST_ID = :ARTIST_ID AND USER_ID = :USER_ID;\n IF SQL%ROWCOUNT = 0 THEN\n :status_code := 404;\n :pv_result := 'Invalid user_id or artist_id number';\n :pn_status := 'NO_MATCH';\n ELSE\n :status_code := 200;\n :pv_result := 'UNLIKED ARTIST SUCCESSFULLY';\n :pn_status := 'SUCCESS';\n END IF;\n EXCEPTION \n WHEN OTHERS THEN \n :status_code := 400;\n :pv_result := 'UNABLE TO UNLIKE ';\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + } + }, + "/authuser/v1/liked_venue": { + "post": { + "operationId": "post_concert_app_authuser_v1_liked_venue", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_venue", + "method": "POST", + "sourceType": "plsql/block", + "source": "BEGIN\n INSERT INTO LIKED_VENUE(VENUE_ID, USER_ID)\n VALUES (:VENUE_ID, :USER_ID);\n :status_code := 201;\n :pv_result := 'VENUE LIKED SUCCESSFULLY';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code := 400;\n :pv_result := 'UNABLE TO LIKE VENUE';\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "delete": { + "operationId": "delete_concert_app_authuser_v1_liked_venue", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_venue", + "method": "DELETE", + "sourceType": "json/collection", + "source": "BEGIN\n DELETE FROM LIKED_VENUE \n WHERE VENUE_ID = :VENUE_ID AND USER_ID = :USER_ID;\n IF SQL%ROWCOUNT = 0 THEN\n :status_code := 404;\n :pv_result := 'Invalid user_id or venue_id number';\n :pn_status := 'NO_MATCH';\n ELSE\n :status_code := 200;\n :pv_result := 'UNLIKED VENUE SUCCESSFULLY';\n :pn_status := 'SUCCESS';\n END IF;\n EXCEPTION \n WHEN OTHERS THEN \n :status_code := 400;\n :PN_RESULT := 'UNABLE TO UNLIKE VENUE';\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + } + }, + "/authuser/v1/liked_event": { + "post": { + "operationId": "post_concert_app_authuser_v1_liked_event", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_event", + "method": "POST", + "sourceType": "plsql/block", + "source": "BEGIN\n INSERT INTO LIKED_EVENT(EVENT_ID, USER_ID)\n VALUES (:EVENT_ID, :USER_ID);\n :status_code := 201;\n :pv_result := 'EVENT LIKED SUCCESSFULLY';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code := 400;\n :pv_result := 'UNABLE TO LIKE EVENT.';\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "delete": { + "operationId": "delete_concert_app_authuser_v1_liked_event", + "tags": [ + "concert_app.authuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_authuser" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.authuser.v1", + "pattern": "liked_event", + "method": "DELETE", + "sourceType": "plsql/block", + "source": "BEGIN\n DELETE FROM LIKED_EVENT \n WHERE EVENT_ID = :EVENT_ID AND USER_ID = :USER_ID;\n IF SQL%ROWCOUNT = 0 THEN\n :status_code := 404;\n :pv_result := 'Invalid user_id or event_id number';\n :pn_status := 'NO_MATCH';\n ELSE\n :status_code := 200;\n :pv_result := 'UNLIKED EVENT SUCCESSFULLY';\n :pn_status := 'SUCCESS';\n END IF;\n EXCEPTION \n WHEN OTHERS THEN \n :status_code := 400;\n :pv_result := 'UNABLE TO UNLIKE EVENT';\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + } + }, + "/adminuser/v1/artists": { + "post": { + "operationId": "post_concert_app_adminuser_v1_artists", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "artists", + "method": "POST", + "sourceType": "plsql/block", + "source": "BEGIN\n INSERT INTO ARTISTS(NAME, DESCRIPTION, BIO)\n VALUES (:name, :description, :bio)\n RETURNING ARTIST_ID INTO :artist_id;\n :status_code:= 201;\n :pv_result := 'Artists Added';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :artist_id := -1;\n :pv_result := 'UNABLE TO ADD ARTIST' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "put": { + "operationId": "put_concert_app_adminuser_v1_artists", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "artists", + "method": "PUT", + "sourceType": "plsql/block", + "source": "BEGIN\n UPDATE ARTISTS A\n SET NAME = nvl(:name, A.NAME), BIO = nvl(:bio, A.BIO)\n WHERE A.ARTIST_ID = :id;\n :status_code:= 201;\n :pv_result := 'Artists Updated';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :pv_result := 'UNABLE TO UPDATE ARTIST' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "delete": { + "operationId": "delete_concert_app_adminuser_v1_artists", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "artists", + "method": "DELETE", + "sourceType": "plsql/block", + "source": "BEGIN\n DELETE FROM ARTISTS\n WHERE ARTIST_ID = :id;\n\n IF SQL%ROWCOUNT = 0 THEN\n :status_code:= 404;\n :pv_result := 'Invalid artist id';\n :pn_status := 'NO_MATCH';\n ELSE\n :status_code:= 200;\n :pv_result := 'Artist Deleted';\n :pn_status := 'SUCCESS';\n END IF;\n\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :pv_result := 'UNABLE TO DELETE ARTIST' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + } + }, + "/adminuser/v1/venues": { + "post": { + "operationId": "post_concert_app_adminuser_v1_venues", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "venues", + "method": "POST", + "sourceType": "plsql/block", + "source": "BEGIN\n INSERT INTO VENUES(NAME, LOCATION, CITY_ID)\n VALUES (:name, :location, :city_id)\n RETURNING VENUE_ID INTO :venue_id; \n :status_code:= 201;\n :pv_result := 'Venue Added';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :pv_result := 'UNABLE TO ADD VENUE' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "put": { + "operationId": "put_concert_app_adminuser_v1_venues", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "venues", + "method": "PUT", + "sourceType": "plsql/block", + "source": "BEGIN\n UPDATE VENUES V\n SET NAME = nvl(:name, V.NAME), LOCATION = nvl(:location, V.LOCATION)\n WHERE V.ID = :id;\n :status_code:= 201;\n :pv_result := 'VENUE Updated';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :pv_result := 'UNABLE TO UPDATE VENUE' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "delete": { + "operationId": "delete_concert_app_adminuser_v1_venues", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "venues", + "method": "DELETE", + "sourceType": "plsql/block", + "source": "BEGIN\n DELETE FROM VENUES\n WHERE VENUE_ID = :id;\n\n IF SQL%ROWCOUNT = 0 THEN\n :status_code:= 404;\n :pv_result := 'Invalid venue id';\n :pn_status := 'NO_MATCH';\n ELSE\n :status_code:= 200;\n :pv_result := 'Venue Deleted';\n :pn_status := 'SUCCESS';\n END IF;\n\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :pv_result := 'UNABLE TO DELETE ARTIST' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + } + }, + "/adminuser/v1/events": { + "post": { + "operationId": "post_concert_app_adminuser_v1_events", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "events", + "method": "POST", + "sourceType": "plsql/block", + "source": "BEGIN\n INSERT INTO EVENTS(EVENT_DATE, ARTIST_ID, VENUE_ID, EVENT_STATUS_ID, EVENT_DETAILS)\n VALUES (TO_DATE(:e_date, 'YYYY-MM-DD'),\n :artist_id, :venue_id, :event_status_id, :event_details)\n RETURNING EVENT_ID INTO :event_id; \n :status_code:= 201;\n :pv_result := 'Event Added';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :pv_result := 'UNABLE TO ADD EVENT' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "put": { + "operationId": "put_concert_app_adminuser_v1_events", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "events", + "method": "PUT", + "sourceType": "plsql/block", + "source": "BEGIN\n UPDATE EVENTS E\n SET \n E_DATE = nvl(TO_DATE(:e_date, 'YYYY-MM-DDTHH:mm:ss.SSSZ'), E.EVENT_DATE), \n ARTIST_ID = nvl(:artist_id, E.ARTIST_ID),\n VENUE_ID = nvl(:venue_id, E.VENUE_ID),\n EVENT_STATUS_ID = nvl(:event_status_id, E.EVENT_STATUS_ID),\n EVENT_DETAILS = nvl(:event_details, E.EVENT_DETAILS),\n WHERE E.ID = :id;\n :status_code:= 201;\n :pv_result := 'Event Updated';\n :pn_status := 'SUCCESS';\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :pv_result := 'UNABLE TO UPDATE EVENT' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + }, + "delete": { + "operationId": "delete_concert_app_adminuser_v1_events", + "tags": [ + "concert_app.adminuser.v1" + ], + "parameters": [], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "security": [ + { + "BEARER": [ + "concert_app_admin" + ] + } + ], + "x-dbtools-operation": { + "moduleName": "concert_app.adminuser.v1", + "pattern": "events", + "method": "DELETE", + "sourceType": "plsql/block", + "source": "BEGIN\n DELETE FROM EVENTS\n WHERE EVENT_ID = :id;\n\n IF SQL%ROWCOUNT = 0 THEN\n :status_code:= 404;\n :pv_result := 'Invalid event id';\n :pn_status := 'NO_MATCH';\n ELSE\n :status_code:= 200;\n :pv_result := 'Event Deleted';\n :pn_status := 'SUCCESS';\n END IF;\n\n EXCEPTION \n WHEN OTHERS THEN \n :status_code:= 400;\n :pv_result := 'UNABLE TO DELETE ARTIST' || SQLERRM;\n :pn_status := 'ERROR';\n END;\n ", + "parameters": [] + } + } + } + }, + "components": { + "securitySchemes": { + "BEARER": { + "type": "oauth2", + "flows": { + "clientCredentials": { + "tokenUrl": ":443/oauth2/v1/token", + "scopes": { + "concert_app_admin": "Provides access to the concert app admin endpoints", + "concert_app_authuser": "Provides access to the user specific endpoints", + "concert_app_euser": "Provides limited access to the concert app endpoints" + } + } + } + } + } + }, + "x-dbtools-properties": { + "itemsPerPage": 1, + "published": "PUBLISHED" + } +} diff --git a/templates/ords-remix-jwt-sample/ords_config_service/generate_from_plsql.mjs b/templates/ords-remix-jwt-sample/ords_config_service/generate_from_plsql.mjs new file mode 100644 index 0000000..dca3d6a --- /dev/null +++ b/templates/ords-remix-jwt-sample/ords_config_service/generate_from_plsql.mjs @@ -0,0 +1,488 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const sourceFile = process.argv[2] || 'Database/Modules/CONCERT_SAMPLE_APP.sql'; +const outDir = process.argv[3] || 'ords_config_service'; +const sql = fs.readFileSync(sourceFile, 'utf8'); + +function parseParenContent(text, openParenIdx) { + let depth = 0; + let inQuote = false; + for (let i = openParenIdx; i < text.length; i += 1) { + const ch = text[i]; + const next = text[i + 1]; + + if (inQuote) { + if (ch === "'" && next === "'") { + i += 1; + continue; + } + if (ch === "'") { + inQuote = false; + } + continue; + } + + if (ch === "'") { + inQuote = true; + continue; + } + + if (ch === '(') { + depth += 1; + continue; + } + + if (ch === ')') { + depth -= 1; + if (depth === 0) { + return { + content: text.slice(openParenIdx + 1, i), + end: i, + }; + } + } + } + + throw new Error(`Unterminated call at index ${openParenIdx}`); +} + +function findCalls(text, callName) { + const found = []; + let pos = 0; + + while (pos < text.length) { + const idx = text.indexOf(callName, pos); + if (idx === -1) break; + + const before = idx > 0 ? text[idx - 1] : ' '; + if (/[A-Za-z0-9_$.]/.test(before)) { + pos = idx + 1; + continue; + } + + let i = idx + callName.length; + while (i < text.length && /\s/.test(text[i])) i += 1; + + if (text[i] !== '(') { + pos = idx + 1; + continue; + } + + const parsed = parseParenContent(text, i); + found.push({ + name: callName, + index: idx, + argsText: parsed.content, + }); + + pos = parsed.end + 1; + } + + return found; +} + +function splitTopLevelArgs(argsText) { + const parts = []; + let buf = ''; + let depth = 0; + let inQuote = false; + + for (let i = 0; i < argsText.length; i += 1) { + const ch = argsText[i]; + const next = argsText[i + 1]; + + if (inQuote) { + buf += ch; + if (ch === "'" && next === "'") { + buf += next; + i += 1; + continue; + } + if (ch === "'") inQuote = false; + continue; + } + + if (ch === "'") { + inQuote = true; + buf += ch; + continue; + } + + if (ch === '(') { + depth += 1; + buf += ch; + continue; + } + + if (ch === ')') { + depth -= 1; + buf += ch; + continue; + } + + if (ch === ',' && depth === 0) { + if (buf.trim()) parts.push(buf.trim()); + buf = ''; + continue; + } + + buf += ch; + } + + if (buf.trim()) parts.push(buf.trim()); + return parts; +} + +function parseSqlStringLiteral(raw) { + const trimmed = raw.trim(); + if (!(trimmed.startsWith("'") && trimmed.endsWith("'"))) return null; + return trimmed.slice(1, -1).replace(/''/g, "'"); +} + +function parseValue(raw) { + const trimmed = raw.trim(); + + const sqlString = parseSqlStringLiteral(trimmed); + if (sqlString !== null) return sqlString; + + if (/^NULL$/i.test(trimmed)) return null; + if (/^TRUE$/i.test(trimmed)) return true; + if (/^FALSE$/i.test(trimmed)) return false; + if (/^-?\d+$/.test(trimmed)) return Number(trimmed); + + return trimmed; +} + +function parseArgs(argsText) { + const args = {}; + for (const part of splitTopLevelArgs(argsText)) { + const marker = part.indexOf('=>'); + if (marker === -1) continue; + + const key = part.slice(0, marker).trim().toLowerCase(); + const valueRaw = part.slice(marker + 2).trim(); + args[key] = parseValue(valueRaw); + } + return args; +} + +const callNames = [ + 'ORDS.DEFINE_MODULE', + 'ORDS.DEFINE_TEMPLATE', + 'ORDS.DEFINE_HANDLER', + 'ORDS.DEFINE_PARAMETER', + 'ORDS.DEFINE_PRIVILEGE', + 'ORDS.DEFINE_ROLE', + 'ORDS.ENABLE_OBJECT', + 'ORDS_METADATA.ORDS.ENABLE_OBJECT', +]; + +const calls = callNames + .flatMap((name) => findCalls(sql, name)) + .map((call) => ({ ...call, args: parseArgs(call.argsText) })) + .sort((a, b) => a.index - b.index); + +const modules = new Map(); +for (const call of calls) { + if (call.name !== 'ORDS.DEFINE_MODULE') continue; + const moduleName = call.args.p_module_name; + if (!moduleName) continue; + + modules.set(moduleName, { + moduleName, + basePath: call.args.p_base_path || '/', + itemsPerPage: Number.isInteger(call.args.p_items_per_page) ? call.args.p_items_per_page : 0, + status: typeof call.args.p_status === 'string' ? call.args.p_status : 'PUBLISHED', + }); +} + +const parameterMap = new Map(); +for (const call of calls) { + if (call.name !== 'ORDS.DEFINE_PARAMETER') continue; + + const moduleName = call.args.p_module_name; + const pattern = call.args.p_pattern; + const method = String(call.args.p_method || '').toUpperCase(); + if (!moduleName || !pattern || !method) continue; + + const key = `${moduleName}||${pattern}||${method}`; + if (!parameterMap.has(key)) parameterMap.set(key, []); + + parameterMap.get(key).push({ + name: String(call.args.p_name || '').trim(), + bindVariable: String(call.args.p_bind_variable_name || '').trim(), + sourceType: call.args.p_source_type, + parameterType: call.args.p_param_type, + accessMethod: call.args.p_access_method, + description: call.args.p_comments || '', + }); +} + +function splitPath(rawPath) { + return String(rawPath) + .split('/') + .map((seg) => seg.trim()) + .filter(Boolean); +} + +function normalizePatternSegment(segment) { + if (segment.startsWith(':')) { + return `{${segment.slice(1)}}`; + } + return segment; +} + +function buildPath(basePath, pattern) { + const segments = [ + ...splitPath(basePath), + ...splitPath(pattern).map(normalizePatternSegment), + ]; + + const built = `/${segments.join('/')}`; + return built === '/' ? built : built.replace(/\/+$/g, ''); +} + +function extractPlaceholders(pattern) { + const placeholders = []; + for (const seg of splitPath(pattern)) { + if (seg.startsWith(':')) placeholders.push(seg.slice(1)); + } + return placeholders; +} + +const privilegeScopes = {}; +for (const call of calls) { + if (call.name !== 'ORDS.DEFINE_PRIVILEGE') continue; + const scope = call.args.p_privilege_name; + if (typeof scope !== 'string') continue; + if (!scope.startsWith('concert_app_')) continue; + + privilegeScopes[scope] = + (typeof call.args.p_description === 'string' && call.args.p_description) || + (typeof call.args.p_label === 'string' && call.args.p_label) || + scope; +} + +const securitySchemeScopes = Object.keys(privilegeScopes).length + ? privilegeScopes + : { + concert_app_admin: 'Provides access to the concert app admin endpoints', + concert_app_authuser: 'Provides access to the user specific endpoints', + concert_app_euser: 'Provides limited access to the concert app endpoints', + }; + +function operationSecurity(moduleName) { + if (moduleName.includes('.authuser.')) return [{ BEARER: ['concert_app_authuser'] }]; + if (moduleName.includes('.adminuser.')) return [{ BEARER: ['concert_app_admin'] }]; + return []; +} + +function responseSchema(sourceType) { + if (String(sourceType || '').toLowerCase().includes('collection')) { + return { + type: 'array', + items: { + type: 'object', + additionalProperties: true, + }, + }; + } + + return { + type: 'object', + additionalProperties: true, + }; +} + +function sanitizeForId(value) { + return value + .replace(/[:{}]/g, '_') + .replace(/[^A-Za-z0-9_]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase(); +} + +const operationIds = new Set(); +function nextOperationId(method, moduleName, pattern) { + const baseId = sanitizeForId(`${method}_${moduleName}_${pattern}`); + if (!operationIds.has(baseId)) { + operationIds.add(baseId); + return baseId; + } + + let idx = 2; + while (operationIds.has(`${baseId}_${idx}`)) idx += 1; + const id = `${baseId}_${idx}`; + operationIds.add(id); + return id; +} + +const paths = {}; + +for (const call of calls) { + if (call.name !== 'ORDS.DEFINE_HANDLER') continue; + + const moduleName = call.args.p_module_name; + const pattern = call.args.p_pattern; + const method = String(call.args.p_method || '').toUpperCase(); + const sourceType = call.args.p_source_type; + const source = call.args.p_source; + + if (!moduleName || !pattern || !method) continue; + if (!modules.has(moduleName)) continue; + + if (typeof source !== 'string' || source.trim().length === 0) { + throw new Error(`Empty p_source for ${moduleName} ${method} ${pattern}`); + } + + const openApiPath = buildPath(modules.get(moduleName).basePath, pattern); + const pathParamNames = extractPlaceholders(pattern); + + const paramsKey = `${moduleName}||${pattern}||${method}`; + const declaredParams = parameterMap.get(paramsKey) || []; + + const pathParameters = pathParamNames.map((name) => ({ + name, + in: 'path', + required: true, + schema: { type: 'string' }, + })); + + const xDbParams = pathParamNames.map((name) => { + const matched = declaredParams.find((param) => { + if (!param) return false; + const byName = String(param.name || '').trim(); + const byBind = String(param.bindVariable || '').trim(); + return byName.toLowerCase() === name.toLowerCase() || byBind.toLowerCase() === name.toLowerCase(); + }); + + return { + name, + bindVariable: matched?.bindVariable || name, + sourceType: matched?.sourceType || 'URI', + parameterType: matched?.parameterType || 'STRING', + accessMethod: matched?.accessMethod || 'IN', + description: matched?.description || '', + }; + }); + + // Strict placeholder/parameter parity. + const pathParamSet = new Set(pathParameters.map((p) => p.name)); + if (pathParamSet.size !== pathParamNames.length) { + throw new Error(`Duplicate path placeholder in ${moduleName} ${pattern}`); + } + + const op = { + operationId: nextOperationId(method, moduleName, pattern), + tags: [moduleName], + parameters: pathParameters, + responses: { + '200': { + description: 'Response', + content: { + 'application/json': { + schema: responseSchema(sourceType), + }, + }, + }, + }, + security: operationSecurity(moduleName), + 'x-dbtools-operation': { + moduleName, + pattern, + method, + sourceType, + source, + parameters: xDbParams, + }, + }; + + if (!paths[openApiPath]) paths[openApiPath] = {}; + paths[openApiPath][method.toLowerCase()] = op; +} + +const moduleItemsPerPage = [...modules.values()].map((moduleDef) => moduleDef.itemsPerPage || 0); +const itemsPerPage = Math.max(1, ...moduleItemsPerPage, 1); +const anyPublished = [...modules.values()].some((moduleDef) => String(moduleDef.status).toUpperCase() === 'PUBLISHED'); + +const apiSpec = { + openapi: '3.1.0', + info: { + title: 'Concert App API Spec', + version: '1.0.0', + }, + paths, + components: { + securitySchemes: { + BEARER: { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://:443/oauth2/v1/token', + scopes: securitySchemeScopes, + }, + }, + }, + }, + }, + 'x-dbtools-properties': { + itemsPerPage, + published: anyPublished ? 'PUBLISHED' : 'UNPUBLISHED', + }, +}; + +const enableObjects = calls + .filter((call) => call.name === 'ORDS.ENABLE_OBJECT' || call.name === 'ORDS_METADATA.ORDS.ENABLE_OBJECT') + .filter((call) => call.args.p_enabled === true && call.args.p_auto_rest_auth === true) + .map((call) => ({ + objectName: String(call.args.p_object || '').trim(), + objectType: String(call.args.p_object_type || '').trim(), + alias: String(call.args.p_object_alias || '').trim(), + })) + .filter((obj) => obj.objectName && obj.objectType && obj.alias); + +const uniqueEnableObjects = []; +const seenAuto = new Set(); +for (const obj of enableObjects) { + const key = `${obj.objectType}::${obj.objectName}`; + if (seenAuto.has(key)) continue; + seenAuto.add(key); + uniqueEnableObjects.push(obj); +} + +const ociLines = []; +ociLines.push('CONTENT=$(jq -c . ords_config_service/apispec.json)'); +ociLines.push(''); +ociLines.push('oci dbtools-runtime database-api-gateway-config-pool-api-spec create default \\\n --database-api-gateway-config-id $CONFIG_OCID \\\n --pool-key $POOL_KEY \\\n --display-name concert_sample_app_apispec \\\n --content "$CONTENT"'); + +for (const obj of uniqueEnableObjects) { + ociLines.push(''); + ociLines.push(`oci dbtools-runtime database-api-gateway-config-pool-auto-api-spec create default \\ + --database-api-gateway-config-id $CONFIG_OCID \\ + --pool-key $POOL_KEY\\ + --display-name "The ${obj.objectName} ${obj.objectType}" \\ + --database-object-name ${obj.objectName} \\ + --database-object-type ${obj.objectType} \\ + --description "This is a rest API of ${obj.objectName}" \\ + --alias "${obj.alias}" \\ + --from-json '{"operations":["READ"]}'`); +} +ociLines.push(''); + +fs.mkdirSync(outDir, { recursive: true }); +const apiSpecPath = path.join(outDir, 'apispec.json'); +const ociRequestsPath = path.join(outDir, 'oci_requests.sh'); + +fs.writeFileSync(apiSpecPath, `${JSON.stringify(apiSpec, null, 2)}\n`); +fs.writeFileSync(ociRequestsPath, `${ociLines.join('\n')}`); + +const apiLen = fs.readFileSync(apiSpecPath, 'utf8').length; +if (apiLen < 2 || apiLen > 102400) { + throw new Error(`apispec.json size must be 2..102400 chars, got ${apiLen}`); +} + +console.log(`Generated ${apiSpecPath}`); +console.log(`Generated ${ociRequestsPath}`); +console.log(`Paths: ${Object.keys(paths).length}, auto objects: ${uniqueEnableObjects.length}`); diff --git a/templates/ords-remix-jwt-sample/ords_config_service/oci_requests.sh b/templates/ords-remix-jwt-sample/ords_config_service/oci_requests.sh new file mode 100755 index 0000000..96f4523 --- /dev/null +++ b/templates/ords-remix-jwt-sample/ords_config_service/oci_requests.sh @@ -0,0 +1,37 @@ +CONTENT=$(jq -c . ords_config_service/apispec.json) + +oci dbtools-runtime database-api-gateway-config-pool-api-spec create default \ + --database-api-gateway-config-id $CONFIG_OCID \ + --pool-key $POOL_KEY \ + --display-name concert_sample_app_apispec \ + --content "$CONTENT" + +oci dbtools-runtime database-api-gateway-config-pool-auto-api-spec create default \ + --database-api-gateway-config-id $CONFIG_OCID \ + --pool-key $POOL_KEY\ + --display-name "The SEARCH_VIEW VIEW" \ + --database-object-name SEARCH_VIEW \ + --database-object-type VIEW \ + --description "This is a rest API of SEARCH_VIEW" \ + --alias "search_view" \ + --from-json '{"operations":["READ"]}' + +oci dbtools-runtime database-api-gateway-config-pool-auto-api-spec create default \ + --database-api-gateway-config-id $CONFIG_OCID \ + --pool-key $POOL_KEY\ + --display-name "The SEARCH_ARTIST_VIEW VIEW" \ + --database-object-name SEARCH_ARTIST_VIEW \ + --database-object-type VIEW \ + --description "This is a rest API of SEARCH_ARTIST_VIEW" \ + --alias "search_artist_view" \ + --from-json '{"operations":["READ"]}' + +oci dbtools-runtime database-api-gateway-config-pool-auto-api-spec create default \ + --database-api-gateway-config-id $CONFIG_OCID \ + --pool-key $POOL_KEY\ + --display-name "The SEARCH_VENUES_VIEW VIEW" \ + --database-object-name SEARCH_VENUES_VIEW \ + --database-object-type VIEW \ + --description "This is a rest API of SEARCH_VENUES_VIEW" \ + --alias "search_venues_view" \ + --from-json '{"operations":["READ"]}' diff --git a/templates/ords-remix-jwt-sample/package.json b/templates/ords-remix-jwt-sample/package.json index 356a9fe..d522b6b 100644 --- a/templates/ords-remix-jwt-sample/package.json +++ b/templates/ords-remix-jwt-sample/package.json @@ -32,7 +32,6 @@ "license": "UPL-1.0", "type": "module", "dependencies": { - "@auth0/auth0-react": "2.2.4", "@emotion/cache": "^11.13.0", "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", @@ -49,11 +48,12 @@ "isomorphic-dompurify": "2.35.0", "material-ui-popup-state": "5.3.6", "node-fetch": "3.3.2", + "oracledb": "^6.8.0", "react": "18.3.1", "react-datepicker": "7.5.0", "react-dom": "18.3.1", "remix-auth": "3.7.0", - "remix-auth-auth0": "1.10.0", + "remix-auth-oauth2": "1.10.0", "vite": "5.4.21" }, "devDependencies": { @@ -92,4 +92,4 @@ "estree-util-value-to-estree": "3.5.0", "tar": "7.5.7" } -} \ No newline at end of file +}