diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 000000000..5c23c8340
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,49 @@
+you are an expert Angular programmer using TypeScript, Angular 18 and Jest that focuses on producing clear, readable code.
+
+this project uses SDKMAN for Java/Kotlin/Maven version management. ensure you run `sdk env install` or `sdk env` to load the versions specified in .sdkmanrc before building or running the project.
+
+RUN TESTS before telling the user that you're done.
+
+run format:check before committing (ui/: npm run format:check). run prettier --write to fix any issues.
+
+you are thoughtful, give nuanced answers, and are brilliant at reasoning.
+
+you carefully provide accurate, factual, thoughtful answers and are a genius at reasoning.
+
+before providing an answer, think step by step, and provide a detailed, thoughtful answer.
+
+if you need more information, ask for it.
+
+write tests for your code.
+
+break down complex problems into smaller, more manageable pieces.
+
+place most abstract / general functions first in files.
+
+prefer a one-concept-per-file approach with a matching .spec.ts file.
+
+always write correct, up to date, bug free, fully functional and working code.
+
+focus on performance, readability, and maintainability.
+
+before providing an answer, double check your work
+
+include all required imports, and ensure proper naming of key components
+
+do not nest code more than 2 levels deep
+
+code should obey the rules defined in the .eslintrc.json, .prettierrc, .htmlhintrc, and .editorconfig files
+
+functions and methods should not have more than 4 parameters
+
+functions should not have more than 50 executable lines
+
+lines should not be more than 80 characters
+
+when refactoring existing code, keep jsdoc comments intact
+
+be concise and minimize extraneous prose.
+
+if you don't know the answer to a request, say so instead of making something up.
+
+linting: configure ktlint in .editorconfig, not in mvn.xml
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..232becddb
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+Dockerfile
+/ui/node_modules
+/ui/reports
+/ui/.angular
+
+# Exclude previously built artifacts
+/ui/dist
+/api/target
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..99c3d7fe0
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,27 @@
+# Editor configuration, see https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{ts,js,json,html,scss,css}]
+indent_size = 2
+
+[*.{kt,kts}]
+indent_size = 4
+max_line_length = 100
+ktlint_standard_no-wildcard-imports = disabled
+ktlint_standard_property-naming = disabled
+
+[**/test/**/*.{kt,kts}]
+ktlint_standard_max-line-length = off
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
+
+[*/target/**]
+ktlint_standard = disabled
diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml
deleted file mode 100644
index 3746eed93..000000000
--- a/.github/workflows/api-tests.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-# This workflow will Run API tests using Newman
-
-name: API Tests with Maven
-
-on:
- push:
- branches: [ develop ]
- pull_request:
- branches: [ develop ]
-
-jobs:
- api-test:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout out branch
- uses: actions/checkout@v2
- - name: Set up JDK 17
- uses: actions/setup-java@v2
- with:
- java-version: '17'
- distribution: 'adopt'
- - name: Maven version
- run: mvn -version
- - name: Run API tests
- run: mvn clean install -DskipTests && mvn verify -pl test -Prun-api-tests
- env:
- OAUTH_CLIENTID: "${{secrets.OAUTH_CLIENTID}}"
- OAUTH_CLIENTSECRET: "${{secrets.OAUTH_CLIENTSECRET}}"
- OAUTH_AUDIENCE: "${{secrets.OAUTH_AUDIENCE}}"
- OAUTH_ISSUER: "${{secrets.OAUTH_ISSUER}}"
- OKTA_USERNAME: "${{secrets.OKTA_USERNAME}}"
- OKTA_PASSWORD: "${{secrets.OKTA_PASSWORD}}"
- OKTA_URL: "${{secrets.OKTA_URL}}"
- BASE_URL: "http://localhost:8080"
- APP_START_CHECK_RETRY_LIMIT: 100
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..b4178f2e8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,176 @@
+name: CI Validation
+
+on:
+ pull_request:
+ branches:
+ - "main"
+ - "feature/*"
+ - "renovate/*"
+
+jobs:
+ version-check:
+ name: Version Consistency Check
+ runs-on: ubuntu-latest
+ if: |
+ github.event.pull_request.draft == false && (
+ contains(github.event.pull_request.head.ref, 'skip-ci') == false
+ )
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Validate version consistency
+ run: |
+ chmod +x bin/validate-versions.sh
+ bin/validate-versions.sh
+
+ ui-build-test:
+ name: UI Build & Tests
+ runs-on: ubuntu-latest
+ if: |
+ github.event.pull_request.draft == false && (
+ contains(github.event.pull_request.head.ref, 'skip-ci') == false
+ )
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: ".nvmrc"
+ cache: "npm"
+ cache-dependency-path: ui/package-lock.json
+
+ - name: Install dependencies
+ working-directory: ui
+ run: npm ci
+
+ - name: Build UI
+ id: ui-build
+ working-directory: ui
+ run: |
+ echo "::group::Building UI"
+ npm run build-prod
+ echo "::endgroup::"
+
+ - name: Validation (format check)
+ id: ui-validation
+ working-directory: ui
+ run: |
+ echo "::group::Validating code format"
+ npm run format:check
+ echo "::endgroup::"
+
+ - name: Lint UI
+ id: ui-lint
+ working-directory: ui
+ run: |
+ echo "::group::Linting UI code"
+ npm run lint
+ echo "::endgroup::"
+
+ - name: Test UI
+ id: ui-test
+ working-directory: ui
+ run: |
+ echo "::group::Running UI tests"
+ npm run ci-test
+ echo "::endgroup::"
+
+ api-build-test:
+ name: API Build & Tests
+ runs-on: ubuntu-latest
+ if: |
+ github.event.pull_request.draft == false && (
+ contains(github.event.pull_request.head.ref, 'skip-ci') == false
+ )
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: "21"
+ distribution: "temurin"
+ cache: "maven"
+
+ - name: Setup SDKMAN
+ uses: sdkman/sdkman-action@master
+ with:
+ candidate: maven
+ version: "3.9.6"
+
+ - name: Maven version
+ run: mvn -version
+
+ - name: Build API module
+ id: api-build
+ run: |
+ echo "::group::Building API module"
+ mvn clean install -pl api -am --batch-mode
+ echo "::endgroup::"
+
+ - name: Run API unit tests
+ id: api-tests
+ run: |
+ echo "::group::Running API unit tests"
+ mvn test -pl api --batch-mode
+ echo "::endgroup::"
+
+# integration-tests:
+# name: Integration Tests
+# runs-on: ubuntu-latest
+# needs: [ ui-build-test, api-build-test ]
+# if: |
+# github.event.pull_request.draft == false && (
+# contains(github.event.pull_request.head.ref, 'skip-ci') == false
+# )
+#
+# steps:
+# - name: Checkout code
+# uses: actions/checkout@v4
+#
+# - name: Set up JDK 21
+# uses: actions/setup-java@v4
+# with:
+# java-version: "21"
+# distribution: "temurin"
+# cache: "maven"
+#
+# - name: Setup SDKMAN
+# uses: sdkman/sdkman-action@master
+# with:
+# candidate: maven
+# version: "3.9.6"
+#
+# - name: Maven version
+# run: mvn -version
+#
+# - name: Build API and test modules
+# id: build-all
+# run: |
+# echo "::group::Building API and test modules"
+# mvn clean install -pl api,test -am -DskipTests --batch-mode
+# echo "::endgroup::"
+#
+# - name: Run API integration tests
+# id: integration-tests
+# run: |
+# echo "::group::Running API integration tests"
+# mvn verify -pl test -Prun-api-tests --batch-mode
+# echo "::endgroup::"
+# env:
+# OAUTH_CLIENTID: "${{secrets.OAUTH_CLIENTID}}"
+# OAUTH_CLIENTSECRET: "${{secrets.OAUTH_CLIENTSECRET}}"
+# OAUTH_AUDIENCE: "${{secrets.OAUTH_AUDIENCE}}"
+# OAUTH_ISSUER: "${{secrets.OAUTH_ISSUER}}"
+# OKTA_USERNAME: "${{secrets.OKTA_USERNAME}}"
+# OKTA_PASSWORD: "${{secrets.OKTA_PASSWORD}}"
+# OKTA_URL: "${{secrets.OKTA_URL}}"
+# BASE_URL: "http://localhost:8080"
+# APP_START_CHECK_RETRY_LIMIT: 100
diff --git a/.github/workflows/main-push.yml b/.github/workflows/main-push.yml
new file mode 100644
index 000000000..475e0eff6
--- /dev/null
+++ b/.github/workflows/main-push.yml
@@ -0,0 +1,29 @@
+name: Main push - Version tag
+
+on:
+ push:
+ branches:
+ - main
+
+concurrency:
+ group: main-push-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: write
+
+jobs:
+ tag-next-version:
+ name: Tag next patch version
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Generate and push tag
+ env:
+ BRANCH: main
+ CI: "true"
+ run: bin/tag-next-version.sh
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
deleted file mode 100644
index 45605844f..000000000
--- a/.github/workflows/maven.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-# This workflow will build a Java project with Maven
-# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
-
-name: Java CI with Maven
-
-on:
- push:
- branches: [ develop ]
- pull_request:
- branches: [ develop ]
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v2
- - name: Set up JDK 17
- uses: actions/setup-java@v2
- with:
- java-version: '17'
- distribution: 'adopt'
- - name: Maven version
- run: mvn -version
- - name: Build with Maven
- run: mvn -B package
diff --git a/.github/workflows/trigger-monorepo-version-bump.yml b/.github/workflows/trigger-monorepo-version-bump.yml
new file mode 100644
index 000000000..b3f6288c2
--- /dev/null
+++ b/.github/workflows/trigger-monorepo-version-bump.yml
@@ -0,0 +1,31 @@
+name: Trigger Monorepo Version Bump
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ trigger:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate GitHub App token
+ id: generate-token
+ uses: actions/create-github-app-token@v2
+ with:
+ app-id: ${{ vars.MONOREPO_APP_ID }}
+ private-key: ${{ secrets.MONOREPO_APP_PRIVATE_KEY }}
+ owner: skybridgeskills
+ repositories: skybridgeskills-monorepo
+
+ - name: Trigger monorepo version bump workflow
+ env:
+ GH_TOKEN: ${{ steps.generate-token.outputs.token }}
+ run: |
+ gh workflow run osmt-bump-version.yml \
+ --repo skybridgeskills/skybridgeskills-monorepo \
+ --ref main
+ echo "✅ Triggered monorepo version bump workflow"
diff --git a/.gitignore b/.gitignore
index fe71f1d3c..730508365 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,12 +15,6 @@ buildNumber.properties
# OSX metadata
**/.DS_Store
-### IntelliJ IDEA ###
-.idea
-*.iws
-*.iml
-*.ipr
-
### NetBeans ###
/nbproject/private/
/nbbuild/
@@ -31,8 +25,10 @@ build/
!**/src/main/**/build/
!**/src/test/**/build/
-### VS Code ###
-.vscode/
+### Cursor ###
+# Cursor plans directory - contains development plans and notes
+.plans
+.cursor/plans
### Local nodejs downloaded for frontend-maven-plugin
ui/node
@@ -46,3 +42,7 @@ osmt*.env
/test/postman/token.env
/test/node_modules
**/target/
+
+.m2-cache
+.m2
+task-logs-*
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 000000000..ab1f4164e
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/IntelliLang.xml b/.idea/IntelliLang.xml
new file mode 100644
index 000000000..8cfd40917
--- /dev/null
+++ b/.idea/IntelliLang.xml
@@ -0,0 +1,286 @@
+
+
+
+
+ Apache HttpClient 4 HTTP Header (org.apache.http)
+
+
+
+
+
+ Apache HttpClient 5 HTTP Header (org.apache.hc.core5)
+
+
+
+
+
+
+
+
+ AsyncQueryRunner (org.apache.commons.dbutils)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Jodd (jodd.db)
+
+
+
+
+
+
+
+ MockServer Header (org.mockserver)
+
+
+
+
+
+
+ MyBatis @Select/@Delete/@Insert/@Update
+
+
+
+
+
+
+
+ QueryRunner (org.apache.commons.dbutils)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ R2DBC (io.r2dbc)
+
+
+
+
+
+ Reactiverse Postgres Client (io.reactiverse)
+
+
+
+
+
+
+
+
+
+
+
+
+ RestAssured HTTP Header (io.restassured)
+
+
+
+
+
+
+
+ SmallRye Axle SqlClient (io.vertx.axle.sqlclient)
+
+
+
+
+
+ SmallRye Mutiny SqlClient (io.vertx.mutiny.sqlclient)
+
+
+
+
+
+ SmallRye Mutiny SqlConnection (io.vertx.mutiny.sqlclient)
+
+
+
+
+
+
+
+ Spring @Cacheable and @CacheEvict
+
+
+
+
+
+
+
+
+
+
+
+ Spring HttpHeaders (org.springframework.http)
+
+
+
+
+
+
+ Spring Integration/Messaging
+
+
+
+
+
+
+ Spring JDBC (org.springframework.jdbc.core.JdbcOperations)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Spring JDBC (org.springframework.jdbc.core.PreparedStatementCreatorFactory)
+
+
+
+
+
+
+ Spring JDBC (org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator)
+
+
+
+
+
+
+
+ Spring Security @PostAuthorize/@PostFilter/@PreAuthorize/@PreFilter/@AuthenticationPrincipal
+
+
+
+
+
+
+
+
+
+ Spring State Machine
+
+
+
+
+
+
+
+ Vert.x SQL Extensions (io.vertx.ext.sql)
+
+
+
+
+
+
+ Vert.x SQL Reactive Extensions (io.vertx.reactivex.ext.sql)
+
+
+
+
+
+
+
+
+
+ Vert.x SqlClient (io.vertx.sqlclient)
+
+
+
+
+
+
+
+
+
+
+ Vert.x SqlClient RxJava2 (io.vertx.reactivex.sqlclient)
+
+
+
+
+
+
+
+
+
+
+
+ WireMock (com.github.tomakehurst.wiremock.client)
+
+
+
+
+
+
+
+ WireMock (com.github.tomakehurst.wiremock.client)
+
+
+
+
+
+
+ WireMock (com.github.tomakehurst.wiremock.client)
+
+
+
+
+
+
+
+ jOOQ (org.jooq.DSLContext)
+
+
+
+
+
+
+
+ rxjava2-jdbc (org.davidmoten.rx.jdbc)
+
+
+
+
+
+
+ SpEL for Spring Cache
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 000000000..a55e7a179
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 000000000..0a3531bfb
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml
new file mode 100644
index 000000000..4ea72a911
--- /dev/null
+++ b/.idea/copilot.data.migration.agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml
new file mode 100644
index 000000000..7ef04e2ea
--- /dev/null
+++ b/.idea/copilot.data.migration.ask.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml
new file mode 100644
index 000000000..1f2ea11e7
--- /dev/null
+++ b/.idea/copilot.data.migration.ask2agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml
new file mode 100644
index 000000000..8648f9401
--- /dev/null
+++ b/.idea/copilot.data.migration.edit.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 000000000..4e87d3386
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 000000000..03d9549ea
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 000000000..7f3184822
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 000000000..8ad8c8610
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/ktlint-plugin.xml b/.idea/ktlint-plugin.xml
new file mode 100644
index 000000000..e8bd90cf2
--- /dev/null
+++ b/.idea/ktlint-plugin.xml
@@ -0,0 +1,7 @@
+
+
+
+ DISTRACT_FREE
+ DEFAULT
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 000000000..0c961a65e
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 000000000..61e45b5ea
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/.idea/osmt.iml b/.idea/osmt.iml
new file mode 100644
index 000000000..c956989b2
--- /dev/null
+++ b/.idea/osmt.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/OSMT_API_Dev.xml b/.idea/runConfigurations/OSMT_API_Dev.xml
new file mode 100644
index 000000000..a93acc3c3
--- /dev/null
+++ b/.idea/runConfigurations/OSMT_API_Dev.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/runConfigurations/OSMT_API_Dev__google_oauth____TEMPLATE.xml b/.idea/runConfigurations/OSMT_API_Dev__google_oauth____TEMPLATE.xml
new file mode 100644
index 000000000..bb43fbd34
--- /dev/null
+++ b/.idea/runConfigurations/OSMT_API_Dev__google_oauth____TEMPLATE.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/OSMT_UI.xml b/.idea/runConfigurations/OSMT_UI.xml
new file mode 100644
index 000000000..0f78acce2
--- /dev/null
+++ b/.idea/runConfigurations/OSMT_UI.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 000000000..35eb1ddfb
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 000000000..ffcab66aa
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..843712cf0
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1,2 @@
+20.18.3
+
diff --git a/.sdkmanrc b/.sdkmanrc
new file mode 100644
index 000000000..e32aadf1f
--- /dev/null
+++ b/.sdkmanrc
@@ -0,0 +1,7 @@
+# Enable auto-env loading with: sdk env install
+# This file is auto-generated by sdkman.
+
+java=21.0.2-tem
+kotlin=2.2.21
+maven=3.9.6
+
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 000000000..fc0245523
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,26 @@
+{
+ "recommendations": [
+ "fwcd.kotlin",
+ "mathiasfrohlich.Kotlin",
+ "vscjava.vscode-java-pack",
+ "vmware.vscode-spring-boot",
+ "angular.ng-template",
+ "johnpapa.angular2",
+ "dbaeumer.vscode-eslint",
+ "vscjava.vscode-maven",
+ "firsttris.vscode-jest-runner",
+ "mtxr.sqltools",
+ "mtxr.sqltools-driver-mysql",
+ "ms-azuretools.vscode-docker",
+ "esbenp.prettier-vscode",
+ "sonarsource.sonarlint-vscode",
+ "redhat.vscode-yaml",
+ "redhat.vscode-xml",
+ "humao.rest-client",
+ "usernamehw.errorlens",
+ "christian-kohler.path-intellisense",
+ "rangav.vscode-thunder-client"
+ ]
+}
+
+
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..7fa93077f
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,66 @@
+{
+ // Kotlin Language Server Configuration
+ "kotlin.languageServer.enabled": true,
+ "kotlin.languageServer.transport": "stdio",
+ "kotlin.compiler.jvm.target": "21",
+ "kotlin.linting.debounceTime": 250,
+ "kotlin.debugAdapter.enabled": true,
+
+ // Java Language Server Configuration (required for Maven projects)
+ "java.configuration.updateBuildConfiguration": "automatic",
+ "java.configuration.maven.userSettings": null,
+ "java.import.maven.enabled": true,
+ "java.import.maven.offline.enabled": false,
+ "java.compile.nullAnalysis.mode": "automatic",
+ "java.configuration.runtimes": [],
+ "java.jdt.ls.java.home": null,
+
+ // Maven Configuration
+ "maven.executable.path": null,
+ "maven.terminal.useJavaHome": true,
+ "maven.terminal.customEnv": [
+ {
+ "environmentVariable": "JAVA_HOME",
+ "value": "${env:JAVA_HOME}"
+ }
+ ],
+
+ // Workspace Configuration
+ "files.exclude": {
+ "**/target": true,
+ "**/node_modules": true,
+ "**/.git": false
+ },
+ "files.watcherExclude": {
+ "**/target/**": true,
+ "**/node_modules/**": true
+ },
+
+ // Editor Configuration for Kotlin
+ "[kotlin]": {
+ "editor.defaultFormatter": "mathiasfrohlich.Kotlin",
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "explicit",
+ "source.fixAll.ktlint": "explicit"
+ },
+ "editor.tabSize": 4,
+ "editor.insertSpaces": true
+ },
+ // ktlint Configuration
+ "kotlin.linter.ktlint.enabled": true,
+ "kotlin.linter.ktlint.codeStyle": "official",
+ "kotlin.linter.ktlint.formatOnSave": true,
+
+ // File Associations
+ "files.associations": {
+ "*.kt": "kotlin",
+ "*.kts": "kotlin"
+ },
+
+ // Search Configuration
+ "search.exclude": {
+ "**/target": true,
+ "**/node_modules": true
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1a418d36..2a00e07b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,19 @@
+# Announcing the release of OSMT 3.1.0
+
+TBD, 2026
+
+## Enhancements
+
+- Public skills list page accessible without authentication
+
# Announcing the release of OSMT 3.0.0
+
Jan 29, 2024
## Enhancements
+
OSMT 3.0.0 brings these new feature enhancements
+
- Versioned API routes with backward compatibility for integrations using the pre-OSMT 3.0 (legacy) API
- Skills (RSDs) now support multiple categories
- Skills (RSDs) now support multiple authors
@@ -15,6 +26,7 @@ OSMT 3.0.0 brings these new feature enhancements
- Categories in Collection
## Updates
+
- Updated UI to Angular v16 (previously v12)
- Upgrade to JDK 17
- Upgrade to spring-boot v3.1.2
@@ -22,24 +34,29 @@ OSMT 3.0.0 brings these new feature enhancements
- Upgrade to ElasticSearch Server v8.11.3
## Developer/Integrator Enhancements
+
- OpenAPI compliant API specification
- New API test suite
- Full support for loading and reindexing a static development dataset via `osmt_cli.sh`
## Model Changes
+
- RSD 'category' -> 'categories'
- RSD 'author' -> 'authors'
## Defect Fixes
+
- Remove 1 RSD from a Collection does not break UI
- Changing Publish state of RSD in Collection does not break UI
- Fix wrapping behavior for card heading
- Removed leading `'-'` character from URLs in export files in `dev` Spring profile
## Removed
+
- Protractor e2e (end-to-end) testing framework (deprecated & unused by OSMT)
## Configuration Changes
+
> **Warning**
> Starting at 2.5.0 there are database changes that will be applied by Flyway, if that is enabled.
> We recommend destroying ElasticSearch storage and recreating / reindexing.
@@ -47,14 +64,19 @@ OSMT 3.0.0 brings these new feature enhancements
**Full Changelog**: https://github.com/wgu-opensource/osmt/compare/2.5.2...3.0.0
------------------------------------------------------------------------
+
# Announcing the release of OSMT 2.5.2
+
April 28, 2023
+
## Enhancements
+
OSMT 2.5.2 brings 1 new enchancement
- Automatic timeouts to ElasticSearch connections (default: 60 seconds)
## Configuration Changes
+
> **Warning**
> Starting at 2.5.0 there are database changes that will be applied by Flyway, if that is enabled.
> We recommend destroying ElasticSearch storage and recreating / reindexing.
@@ -62,8 +84,11 @@ OSMT 2.5.2 brings 1 new enchancement
**Full Changelog**: https://github.com/wgu-opensource/osmt/compare/2.5.1...2.5.2
------------------------------------------------------------------------
+
# Announcing the release of OSMT 2.5.1
+
March 27, 2023
+
## Defect Fixes
- Fixes to Collection publish statuses
@@ -71,6 +96,7 @@ March 27, 2023
- Fixes to actions when using 'select all' checkboxes
## Configuration Changes
+
> **Warning**
> Starting at 2.5.0 there are database changes that will be applied by Flyway, if that is enabled.
> We recommend destroying ElasticSearch storage and recreating / reindexing.
@@ -78,9 +104,13 @@ March 27, 2023
**Full Changelog**: https://github.com/wgu-opensource/osmt/compare/2.5.0...2.5.1
------------------------------------------------------------------------
+
# Announcing the release of OSMT 2.5.0
+
February 28, 2023
+
## Enhancements
+
OSMT 2.5.0 brings 2 new feature enhancements
- Addition of a "My Workspace" feature, allowing a collection-like place for a user to save and sort RSDs. RSDs can be added to and removed from the Workspace, and the Workspace can be converted to a real Collection, and exported as a CSV without creating a real Collection. The Workspace can also be reset (emptied).
@@ -90,6 +120,7 @@ OSMT 2.5.0 brings 2 new feature enhancements
- An RSD now has a "Copy Public URL" button, to simplify copying the canonical URL for that RSD.
## Configuration Changes
+
> **Warning**
> 2.5.0 has database changes that will be applied by Flyway, if that is enabled.
> We recommend destroying ElasticSearch storage and recreating / reindexing.
@@ -97,7 +128,9 @@ OSMT 2.5.0 brings 2 new feature enhancements
**Full Changelog**: https://github.com/wgu-opensource/osmt/compare/2.4.2...2.5.0
------------------------------------------------------------------------
+
# Announcing the release of OSMT 2.4.2
+
January 27, 2023
> # This release enables OSMT roles by default.
@@ -105,33 +138,42 @@ January 27, 2023
> See [Role-based Access in OSMT](https://github.com/wgu-opensource/osmt/blob/develop/README.md#role-based-access-in-osmt) in the main README.md for configuration details.
# Enhancements
+
* OSMT users can now export selected search result RSDs as CSV.
* OSMT admins can now export draft Collections as CSV
* OSMT admins can now delete any Collection
# Configuration changes
+
> **Warning**
+
* This release enables OSMT roles by default.
See [Role-based Access in OSMT](https://github.com/wgu-opensource/osmt/blob/develop/README.md#role-based-access-in-osmt) in the main README.md for configuration details.
> **Warning**
+
* This release removes all OSMT application*.properties file from the osmt-api-lib Maven artifact. This library allows OSMT Spring endpoints to operate while added as a dependency within another Spring Boot application.
**Full Changelog**: https://github.com/wgu-opensource/osmt/compare/2.4.1...2.4.2
------------------------------------------------------------------------
+
# Announcing the release of OSMT 2.3.0
+
January 6, 2023
This release brings an upgrade to Kotlin, from 1.5.10 to 1.7.21. This release also includes many npm dependency upgrades.
## Defect Fixes
+
* Corrected an issue with secondary RSD sort order.
**Full Changelog**: https://github.com/wgu-opensource/osmt/compare/2.2.0...2.3.0
------------------------------------------------------------------------
+
# Announcing the release of OSMT 2.2.0
+
December 16, 2022
This release brings several defect fixes, and completes the feature enhancement for roles within OSMT (admin, curator, viewer).
@@ -139,43 +181,52 @@ This release brings several defect fixes, and completes the feature enhancement
## Deploying this release will require a delete and reindex for the supporting ElasticSearch indices. See [Reindex after changes to Elasticsearch @Document index classes](https://github.com/wgu-opensource/osmt/blob/develop/api/README.md#reindex-after-changes-to-elasticsearch-document-index-classes) in the API module README.md for guidance.
## Defect fixes:
+
* RSD results are now presented in a consistent sort order by category and name, case insensitive. Previously, when results where sorted by category (the default), the RSDs were correctly sorted by category, but therein not sorted consistently. This would sometimes cause an RSD to appear on multiple pages in the results. When paging through results to add RSDs to collections, a given RSD might be shown twice, or not be shown at all. All previous sorting was done case sensitive, so "AWS" would appear before "Abstract".
-* A quoted search terms will now return RSDs matching that exact complete term. Previously, searching "Communications" in Category would return all RSDs containing that term, including both "Communications" and "Argumentative and Alternative Communications". When quoting the search term, the expected behavior is to only return RSDs with the exact category "Communications", and not others like "Argumentative and Alternative Communications".
+* A quoted search terms will now return RSDs matching that exact complete term. Previously, searching "Communications" in Category would return all RSDs containing that term, including both "Communications" and "Argumentative and Alternative Communications". When quoting the search term, the expected behavior is to only return RSDs with the exact category "Communications", and not others like "Argumentative and Alternative Communications".
## Enhancements
+
* The complete RSD library can now be exported as CSV by OSMT users with the Admin role. If the instance is deployed with roles disabled, then any authenticated user can export the library.
## Patching and Developer Tooling:
+
* Base Docker images were upgraded to oraclelinux:9, as CentOS has been retired.
* Added documentation supporting an LDD for OSMT. See [here](https://github.com/wgu-opensource/osmt/tree/develop/docs/arch) for more details.
------------------------------------------------------------------------
# Announcing the release of OSMT 2.1.0
+
Novemeber 4, 2022
This release brings several defect fixes, and completes the feature enhancement for roles within OSMT (admin, curator, viewer).
## Defect fixes:
+
- RSD result counts greater than 10,000 are now accurately reports in the UI
- BLS and O*NET job code hierarchies have been corrected.
- - This was an import issue, and to benefit from this fix, you will need to reimport the 2018 BLS Job Code metadata, and reindex your ElasticSearch instance. See https://github.com/wgu-opensource/osmt/discussions/251 for more details about using this fix.
+ - This was an import issue, and to benefit from this fix, you will need to reimport the 2018 BLS Job Code metadata, and reindex your ElasticSearch instance. See https://github.com/wgu-opensource/osmt/discussions/251 for more details about using this fix.
- The back button now returns from an RSD detail to the originating search results. Previously, the back button return the user to the RSD full library.
- Resolved issue where all result RSD were added to a collection (instead of just the selected RSDs)
- Fixed a field mapping for CollectionDoc. This would have resulted in nulls in JSON
## Enhancements:
+
- Completed UI implementation of role-based access in OSMT. See "Role-based Access in OSMT" in README.md
## Patching and Developer Tooling:
+
- Adjusted/upgraded OpenCSV and Jackson DataBind dependencies to address critical vulnerabilities
- - CVE-2022-42889
- - CVE-2022-42004
+ - CVE-2022-42889
+ - CVE-2022-42004
- Added development as default local Angular configuration
- - The Maven build continues to run a production Angular build, but the npm commands now default to development
+ - The Maven build continues to run a production Angular build, but the npm commands now default to development
------------------------------------------------------------------------
+
# Announcing OSMT Release 2.0.0
+
July 22, 2022
The main changes in this release are in packaging and distribution.
@@ -185,31 +236,43 @@ The main changes in this release are in packaging and distribution.
* This release unifies an internal WGU fork of OSMT code with the open source code base. The Spring Boot API endpoints can now be used as a dependency in another Spring application (which was an internal requirement for WGU's operational standards). The API module builds the typical repackaged Spring Boot application jar, but also builds a normal Java jar with a "lib" classifier (osmt-api-lib). You can declare this as a Maven dependency in an independent Spring application. This osmt-api-lib artifact has certain exclusions, including the embedded Angular UI files. See api/pom.xml for the specifics.
------------------------------------------------------------------------
+
# Announcing OSMT Release 1.1.0
+
January 25, 2022
### Notable Changes in Release 1.1.0:
+
This release is focused on preparing OSMT for public contributors, with some feature enhancements.
+
#### Existing OSMT Instances - delete and rebuild ElasticSearch index
+
* Because of internal application framework upgrades, existing OSMT instances that upgrade to version 1.1.0 will need to delete and reindex ElasticSearch indices. See [Reindex after changes to Elasticsearch @Document index classes](https://github.com/wgu-opensource/osmt/blob/develop/api/README.md#reindex-after-changes-to-elasticsearch-document-index-classes) for details. You can also follow discussions on this topic [here](https://github.com/wgu-opensource/osmt/discussions/141).
+
#### Features and Enhancements:
+
- added support for anonymous API searching
- added support for filtering by UUID when searching for skills
+
#### Development Tooling:
+
- added OSMT CLI to simplify local development and evaluation, and assist in Discussion forum support.
- added more complete support for a Quickstart evaluation config and a Development configuration, with more clear support for Angular/webpack servers as a front-end proxy
- added official OSMT app Docker image to DockerHub
- - (https://hub.docker.com/repository/docker/wguopensource/osmt-app)
+ - (https://hub.docker.com/repository/docker/wguopensource/osmt-app)
- upgraded to Spring Boot 2.5.5 / Kotlin 1.5.10
- upgraded log4j-related dependencies
- updated OpenAPI specs files
- added many documentation and development tooling refinements
------------------------------------------------------------------------
+
# Announcing OSMT Release 1.0.5
+
October 21, 2021
## What's Changed
+
* Fixed bug: '1 collection' was sometimes '1 collections' (found through new test) by @drey-bigney in https://github.com/wgu-opensource/osmt/pull/3
* added a new file called license by @wgu-edwin in https://github.com/wgu-opensource/osmt/pull/4
* Created code of conduct file by @wgu-edwin in https://github.com/wgu-opensource/osmt/pull/6
@@ -239,6 +302,7 @@ October 21, 2021
* Changes for import functionality. by @karan-beyond in https://github.com/wgu-opensource/osmt/pull/60
## New Contributors
+
* @drey-bigney made their first contribution in https://github.com/wgu-opensource/osmt/pull/3
* @wgu-edwin made their first contribution in https://github.com/wgu-opensource/osmt/pull/4
* @coffindragger made their first contribution in https://github.com/wgu-opensource/osmt/pull/8
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a0fede004..8550619e3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,17 +33,17 @@ We use GitHub's [Issue Tracker](https://github.com/wgu-opensource/osmt/issues).
- Our E2E tests should be implemented in WebdriverIO as automated browser tests, and should require walking through functionality on an actual OSMT instance. These are the most expensive and time-consuming tests. They are needed to ensure that layers of the application are wired up correctly, but should be used sparingly.
## Release / Branching Strategy
-The OSMT project will follow the [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) model, with
-* Enhancement and bug fix work done on feature branches (```feature/branch-name```)
-* Feature branches merge into ```develop```, as the integration branch
-* Releases are cut from ```develop``` (as ```release-branch-name```), and merged back in to both ```master``` and ```develop```
->
+The OSMT project uses `main` as the primary integration branch:
+* Enhancement and bug fix work done on feature branches (`feature/branch-name`)
+* Feature branches merge into `main`
+* Patch versions are tagged automatically on each merge to `main`
+* See [docs/versioning.md](docs/versioning.md) for versioning details
### Using git with this project
1. Use the project's [Issue Tracker](https://github.com/wgu-opensource/osmt/issues) to find an issue that you want to address, or a feature that you would like to add.
2. Clone the repository to your local machine using `git clone https://github.com/wgu-opensource/osmt.git` or `git@github.com:wgu-opensource/osmt.git` for SSH.
-3. Create a new branch for your fix using `git checkout origin/develop -b your-local-branch-name`.
- - Note, make sure you only branch from `origin/develop`!
+3. Create a new branch for your fix using `git checkout origin/main -b your-local-branch-name`.
+ - Note, make sure you only branch from `origin/main`!
4. Make the appropriate changes for the issue you are trying to address, or the feature that you want to add. Include appropriate test coverage.
5. Use `git add insert-paths-of-changed-files-here` to stage the changed files for the commit.
6. Use `git commit` to commit the contents of the index. This should open an editor; please provide a useful commit message (see below for [more about commit messages](#commit-message-format))
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 2fc2fec8e..000000000
--- a/Dockerfile
+++ /dev/null
@@ -1,17 +0,0 @@
-######################
-## SPRING APP IMAGE ##
-######################
-FROM wguopensource/osmt-base:latest
-
-ARG MVN_POM_VERSION
-
-COPY --chown=${USER}:${USER} ./import ${BASE_DIR}/import/
-RUN curl --output ${BASE_DIR}/bin/osmt.jar https://repo1.maven.org/maven2/edu/wgu/osmt/osmt-api/${MVN_POM_VERSION}/osmt-api-${MVN_POM_VERSION}.jar
-RUN chown ${USER}:${USER} ${BASE_DIR}/bin/osmt.jar
-
-
-ADD ./docker/ /${BASE_DIR}/
-
-EXPOSE 8080
-
-ENTRYPOINT ["/opt/osmt/bin/docker_entrypoint.sh"]
diff --git a/README.md b/README.md
index 9074a293d..31e173bfa 100644
--- a/README.md
+++ b/README.md
@@ -1,120 +1,154 @@
# WGU Open Skills Management Toolset (OSMT)
## Overview
+
The Open Skills Management Tool (OSMT, pronounced "oz-mit") is a free, open-source instrument to facilitate the production of Rich Skill Descriptor (RSD) based open skills libraries. In short, it helps to create a common skills language by creating, managing, and organizing skills-related data. An open-source framework allows everyone to use the tool collaboratively to define the RSD, so that those skills are translatable and transferable across educational institutions and hiring organizations within programs, curricula, and job descriptions.
## Architecture
+
OSMT is written in Kotlin with Spring Boot, and Angular. It uses backend-instances of MySQL, Redis, and Elastisearch.
[
](docs/arch/Architectural-Diagram.png)
## Dependencies
-OSMT uses Elasticsearch, Redis, and MySQL as back-end dependencies. OSMT also requires an OAuth2 provider. Local / non-production configurations can be stood up using only Docker. See additional notes below for [Configuration](README.md#configuration).
+
+OSMT uses Elasticsearch, Redis, and MySQL as back-end dependencies. OSMT supports OAuth2 authentication, but it's optional for local development. The `single-auth` profile allows running OSMT locally with simple admin authentication. Local / non-production configurations can be stood up using only Docker. See additional notes below for [Configuration](README.md#configuration).
## Getting started
+
Follow the steps in the [Pre-requisites](README.md#pre-requisites) section.
-* Please have the right technical people in your organization deploy a production OSMT instance before making any real investment in effort for building skills.
-* For latest updates, reach out in the [Discussion](https://github.com/wgu-opensource/osmt/discussions) boards in GitHub.
+
+- Please have the right technical people in your organization deploy a production OSMT instance before making any real investment in effort for building skills.
+- For latest updates, reach out in the [Discussion](https://github.com/wgu-opensource/osmt/discussions) boards in GitHub.
### Using the OSMT CLI utility (`osmt_cli.sh`)
+
The OSMT source code includes a utility named `osmt_cli.sh`, in the project root directory. `osmt_cli.sh` simplifies setting up a local OSMT environment and doing routine tasks. It uses BASH, and works on MacOS and Linux. We have had some success with a BASH interpreter in Windows. You can run `./osmt_cli.sh -h` for the help text.
-* `osmt_cli.sh` uses git to help identify directory context. If you downloaded the source code as a ZIP file, you will need to have git installed to use `osmt_cli.sh`.
+
+- `osmt_cli.sh` uses git to help identify directory context. If you downloaded the source code as a ZIP file, you will need to have git installed to use `osmt_cli.sh`.
### Pre-requisites
-By default, OSMT is wired up for Okta as an OAuth2 provider. While you can change this, these documents will only address configuring Okta.
-1. Obtain a "free developer" Okta account. This is required to log in to a local OSMT. Please follow the steps in [OAuth2 and Okta Configuration](README.md#oauth2-and-okta-configuration) below, and return here when complete. You can refer to [Environment file for Development and API Tests Stack](README.md#environment-files-for-development-and-api-tests-stacks) for more details.
-2. Initialize your environment files. After running this, update the OAUTH2/OIDC values in the env files (replace the `xxxxxx` values with the correct values from your Okta account)
- ```
- ./osmt_cli.sh -i
- ```
+OSMT supports two authentication modes for local development:
+
+- **OAuth2 with Okta** (recommended for production-like testing): Follow the steps in [OAuth2 and Okta Configuration](README.md#oauth2-and-okta-configuration) below.
+- **Single-Auth mode** (simplest for local development): No OAuth2 provider required. OSMT will automatically use the `single-auth` profile when OAuth credentials are missing. See [Local Development Without OAuth2](README.md#local-development-without-oauth2) for details.
+
+1. Initialize your environment files:
+ ```
+ ./osmt_cli.sh -i
+ ```
+ - **For OAuth2 mode**: Update the OAUTH2/OIDC values in the env files (replace the `xxxxxx` values with the correct values from your Okta account)
+ - **For single-auth mode**: You can leave the `xxxxxx` values as-is. OSMT will automatically detect missing OAuth credentials and use the `single-auth` profile.
### Running the Development configuration
+
1. Validate your local environment (for Docker, Java, and other SDKs / runtimes). If this command reports error, you will need to resolve them before running the Development configuration. See more in the [Requirements to Build OSMT](README.md#requirements-to-build-osmt) section
- ```
- ./osmt_cli.sh -v
- ```
+
+ ```
+ ./osmt_cli.sh -v
+ ```
2. **From the project root**, run a Maven build. The API module currently depends on the UI module.
- ```
- mvn clean install
- ```
- * The Maven build starts its own Docker containers for testing purposes. It may disrupt any existing OSMT-related Docker containers you have running.
+
+ ```
+ mvn clean install
+ ```
+
+ - The Maven build starts its own Docker containers for testing purposes. It may disrupt any existing OSMT-related Docker containers you have running.
3. Start (restart) the back-end MySQL/Redis/ElasticSearch dependencies.
- * This starts the dev-stack.yml docker-compose stack. It will run "detached", meaning running in the background. You can use `docker ps` and `docker logs` to see what the services are doing.
- ```
- ./osmt_cli.sh -d
- ```
-
-5. If you prefer, you can import BLS and O*NET job code metadata. This step is optional. See [Post-installation (BLS, O*NET, etc)](README.md#post-installation-bls-onet-etc) and [](api/README.md#importing-data) for more details.
- ```
- ./osmt_cli.sh -m
- ```
-
-6. Start the Spring application.
- * This automatically sources the `api/osmt-dev-stack.env` file. It will also serve the static Angular files.
- * You can exit the Spring application by pressing [Ctrl-C].
+
+ - This starts the dev-stack.yml docker-compose stack. It will run "detached", meaning running in the background. You can use `docker ps` and `docker logs` to see what the services are doing.
+
+ ```
+ ./osmt_cli.sh -d
+ ```
+
+4. If you prefer, you can import BLS and O*NET job code metadata. This step is optional. See [Post-installation (BLS, O*NET, etc)](README.md#post-installation-bls-onet-etc) and [](api/README.md#importing-data) for more details.
+
+ ```
+ ./osmt_cli.sh -m
+ ```
+
+5. Start the Spring application.
+
+ - This automatically sources the `api/osmt-dev-stack.env` file. It will also serve the static Angular files.
+ - You can exit the Spring application by pressing [Ctrl-C].
+
```
./osmt_cli.sh -s
```
-7. Shut down the back-end MySQL/Redis/ElasticSearch dependencies.
- * This stops the dev-stack.yml docker-compose stack.
- ```
- ./osmt_cli.sh -e
- ```
+6. Shut down the back-end MySQL/Redis/ElasticSearch dependencies.
+ - This stops the dev-stack.yml docker-compose stack.
+ ```
+ ./osmt_cli.sh -e
+ ```
### Housekeeping with `osmt_cli.sh`
+
You can surgically clean up OSMT-related Docker images and data volumes. This step **will** delete data from Development and API Test configurations. It does not remove the mysql/redis/elasticsearch Docker images, as those may be available locally for other purposes.
-
+
```
./osmt_cli.sh -c
```
### Using the CI Static Dataset
+
OSMT source code has a [static/fixed dataset](test/sql/fixed_ci_dataset.sql) included in the repo, to be used for automated testing in a CI (continuous integration) environment. This dataset is meant to be in sync with the application as of any given commit (i.e., make a DB change, update the SQL script).
This dataset is meant as an on-ramp for the local developer, and as a fixed quantity for automated testing. It includes about 1200 RSDs and several collections, both in different publish states. As we expand our automated testing around OSMT, we should adapt this dataset to cover test cases as needed.
You can follow these steps to apply this dataset:
+
1. Build your OSMT application
- ```
- mvn clean install
- ```
+ ```
+ mvn clean install
+ ```
2. Destroy your local OSMT-related Docker volumes.
- ```
- ./osmt_cli.sh -c
- ```
+ ```
+ ./osmt_cli.sh -c
+ ```
3. Start the Spring Boot application with `./osmt_cli.sh -s` (this command also starts the Docker stack). Spring Boot will build the database schema via Flyway. Leave this shell open with the Spring Boot app running.
- ```
- ./osmt_cli.sh -s
+ ```
+ ./osmt_cli.sh -s
```
4. From another shell, load the CI static dataset. This command empties all MySQL tables, and requires the mysql client.
- ```
- ./osmt_cli.sh -l
- ```
+ ```
+ ./osmt_cli.sh -l
+ ```
5. From this same shell, reindex OSMT's ElasticSearch. When this is complete, you can close this 2nd shell.
- ```
- ./osmt_cli.sh -r
- ```
+ ```
+ ./osmt_cli.sh -r
+ ```
## Building OSMT
### Requirements to Build OSMT Locally
+
OSMT requires certain software and SDKs to build:
-* Docker >=17.06.0
- * Recommended 6 GB memory allocated to the Docker service. On Windows, Docker will possibly need more memory.
-* a Java 17 JDK (OpenJDK works fine)
-* Maven 3.8.3 or higher
-* NodeJS v18.18.2 / npm 9.8.1 or higher
- * NodeJS/npm are used for building client code; there are no runtime NodeJS/npm dependencies.
- * In the `ui` module, `frontend-maven-plugin` uses an embedded copy of Node v16.13.0 and npm 8.1.0.
- * Locally, a developer probably has their own versions of NodeJS and npm installed. They should be >= the versions given above.
+
+- Docker >=17.06.0
+ - Recommended 6 GB memory allocated to the Docker service. On Windows, Docker will possibly need more memory.
+- a Java 17 JDK (OpenJDK works fine)
+- Maven 3.8.3 or higher
+- NodeJS v18.18.2 / npm 9.8.1 or higher
+ - NodeJS/npm are used for building client code; there are no runtime NodeJS/npm dependencies.
+ - In the `ui` module, `frontend-maven-plugin` uses an embedded copy of Node v16.13.0 and npm 8.1.0.
+ - Locally, a developer probably has their own versions of NodeJS and npm installed. They should be >= the versions given above.
+
+#### Version Management
+
+OSMT includes version management configuration files to help ensure consistent development environments:
+
+- **`.sdkmanrc`** - Specifies Java, Kotlin, and Maven versions. If you use [SDKMAN](https://sdkman.io/), run `sdk env install` to install the required versions, or `sdk env` to activate them. SDKMAN will automatically switch versions when you `cd` into the project directory if your shell is configured for auto-env.
+- **`.nvmrc`** - Specifies the Node.js version. If you use [nvm](https://github.com/nvm-sh/nvm), run `nvm install` to install the required version, or `nvm use` to activate it. nvm will automatically switch versions when you `cd` into the project directory if your shell is configured for auto-switching.
Run `osmt_cli.sh -v` for feedback on your local SDKs and runtimes. The output of this command may be helpful in troubleshooting with the OSMT community as well.
### Project Structure
+
OSMT is a multi-module Maven project. pom.xml files exist in the project root, `./api` and `./ui` directories. Running the command `mvn clean install` from the project root will create a fat jar in the target directory that contains both the backend server and the prod-built Angular frontend static files.
-- project root
@@ -124,138 +158,242 @@ OSMT is a multi-module Maven project. pom.xml files exist in the project root, `
|-- docker - Docker resources for base images and development
\-- ui - Angular frontend
-* `mvn package` may work fine, but the `api` module depends on artifacts from the `ui` module. `mvn install` will place the ui artifacts in your local Maven repo, and will decouple your local builds from the presence of the jar file being present in `ui/target`.
+- `mvn package` may work fine, but the `api` module depends on artifacts from the `ui` module. `mvn install` will place the ui artifacts in your local Maven repo, and will decouple your local builds from the presence of the jar file being present in `ui/target`.
The [API](api/README.md) and [UI](ui/README.md) modules have their own README.md files with more specific information about those modules.
### Configuration
-This project does "Development" configuration. It relies on docker-compose and environment files. Follow the steps in [Pre-requisites](README.md#pre-requisites) above.
+
+This project does "Development" configuration. It relies on docker-compose and
+environment files. Follow the steps in [Pre-requisites](README.md#pre-requisites)
+above. For detailed authentication setup (single-auth, Okta, Google, staging),
+see [Authentication](docs/auth.md).
### Development Configuration
+
The Development configuration uses the `dev-stack.yml` docker-compose file in the `docker` directory, for standing up just the back-end dependencies. This facilitates doing active development in the Spring or Angular layers. You can use `osmt_cli.sh` in the steps given in [Running the Development configuration](README.md#running-the-development-configuration) to simplify starting and stopping the Docker services and Spring application.
-* You are not required to use `osmt_cli.sh`.
- * You can run this command to stand up the Development configuration.
+- You are not required to use `osmt_cli.sh`.
+ - You can run this command to stand up the Development configuration.
```
cd docker; docker-compose --file dev-stack.yml up
```
- * You can manage the local Spring application directly. See [Running from the Command Line](api/README.md#running-from-the-command-line) for details
+ - You can manage the local Spring application directly. See [Running from the Command Line](api/README.md#running-from-the-command-line) for details
If doing front-end development, start the UI Angular front end. The Angular app proxies requests to the backend server during development. This allows one to use Angular's live reloading server.
- - From the `ui` directory, run these commands:
+
+- From the `ui` directory, run these commands:
- `npm install`
- `npm start`
- - Open your browser to `http://localhost:4200`.
+- Open your browser to `http://localhost:4200`.
### OAuth2 and Okta Configuration
+
To use Okta as your OAuth2 provider, you will need a free developer account with [Okta Free Account](https://developer.okta.com/signup). While the user interface at Okta may change, the big ideas of configuring an application for an OAuth/OpenID Connect provider should still apply. From your Okta Dashboard:
-Before you start with these steps, you may be required to update your goals on the Okta website.
+Before you start with these steps, you may be required to update your goals on the Okta website.
+
1. If given the option, navigate to the "Admin" section.
2. Navigate to Applications. Create an Application Integration, select the "OIDC - OpenID Connect" option and "Web Application" option.
3. Under the "General Settings" area:
- - Enter an "App integration name". The intention here is local OSMT development.
- - Enter a "Sign-in redirect URIs", use `http://localhost:8080/login/oauth2/code/okta`
- - Enter a "Sign-out redirect URIs", use `http://localhost:8080`
+ - Enter an "App integration name". The intention here is local OSMT development.
+ - Enter a "Sign-in redirect URIs", use `http://localhost:8080/login/oauth2/code/okta`
+ - Enter a "Sign-out redirect URIs", use `http://localhost:8080`
4. Under the "Assignments" area:
- - Choose "Skip group assignment for now".
+ - Choose "Skip group assignment for now".
5. Save your new Web Application Integration. Okta should show your new Wep App, with a few tabs towards the top.
6. In the "General" tab, under "Client Credentials":
- - Copy/paste the value for Client ID into your osmt-*.env file, for `OAUTH_CLIENTID`.
- - Copy/paste the value for Client Secret into your osmt-*.env file, for `OAUTH_CLIENTSECRET`.
+ - Copy/paste the value for Client ID into your osmt-\*.env file, for `OAUTH_CLIENTID`.
+ - Copy/paste the value for Client Secret into your osmt-\*.env file, for `OAUTH_CLIENTSECRET`.
7. In the "Sign-On" tab, under "OpenID Connect ID Token":
- - Click "Edit", and for Issuer, choose the option that actually has an Okta URL. Save the OpenID Connect ID Token.
- - Copy/paste the value for Issuer into your osmt-*.env file, for OAUTH_ISSUER. Ensure your URL has the `https://` protocol.
- - You may need to ensure your issuer URL ends with `/oauth2/default` ie `https://dev-XXXXX.okta.com/oauth2/default`
- - Copy/paste the value for Audience into your osmt-*.env file, for OAUTH_AUDIENCE.
+ - Click "Edit", and for Issuer, choose the option that actually has an Okta URL. Save the OpenID Connect ID Token.
+ - Copy/paste the value for Issuer into your osmt-\*.env file, for OAUTH_ISSUER. Ensure your URL has the `https://` protocol.
+ - You may need to ensure your issuer URL ends with `/oauth2/default` ie `https://dev-XXXXX.okta.com/oauth2/default`
+ - Copy/paste the value for Audience into your osmt-\*.env file, for OAUTH_AUDIENCE.
8. In the Assignments tab:
- - Click "Assign", and choose "Assign to People". For your Okta user ID, click "Assign". Leave defaults; then click "Save and Go Back".
+ - Click "Assign", and choose "Assign to People". For your Okta user ID, click "Assign". Leave defaults; then click "Save and Go Back".
+
+When using Okta, you will use the `oauth2` profile for Spring Boot, which will include the properties from [application-oauth2.properties](api/src/main/resources/config/application-oauth2.properties). This properties file relies on secrets being provided via the environment. The commands in `osmt_cli.sh` automatically provide the appropriate environment files.
+
+### OAuth2 with Google
+
+OSMT supports Google as an OAuth2 provider. Configure credentials in [Google Cloud Console](https://console.cloud.google.com/):
+
+1. Create an OAuth 2.0 Client ID (Web application)
+2. Add authorized redirect URI: `{baseUrl}/login/oauth2/code/google` (e.g. `http://localhost:8080/login/oauth2/code/google`)
+3. Set `OAUTH_GOOGLE_CLIENT_ID` and `OAUTH_GOOGLE_CLIENT_SECRET` in your env file
+
+**Role mapping for Google:** Google OIDC does not include groups/roles by default. Use `app.enableRoles=false` for simple auth, or configure custom claims in GCP/Workspace to add roles. See `app.oauth2.rolesClaim` in application.properties for configurable claim name.
+
+### Staging: Google + Single-Auth
+
+For staging environments where both OAuth (Google) and single-auth admin login are desired:
-When using Okta, you will use the `oauth2-okta` profile for Spring Boot, which will include the properties from [application-oauth2-okta.properties](api/src/main/resources/config/application-oauth2-okta.properties). This properties file relies on secrets being provided via the environment. The commands in `osmt_cli.sh` automatically provide the appropriate environment files.
+1. Set `OAUTH_GOOGLE_CLIENT_ID` and `OAUTH_GOOGLE_CLIENT_SECRET`
+2. Set `ENABLE_SINGLE_AUTH=true`
+3. Configure `SINGLE_AUTH_ADMIN_USERNAME` and `SINGLE_AUTH_ADMIN_PASSWORD`
+
+The login page will show both "Sign in with Google" and admin credentials form. See [api/osmt-staging.env.example](api/osmt-staging.env.example).
If you want to enable OSMT user permissions by roles, see additional details in [Role-based Access in OSMT](README.md#role-based-access-in-osmt).
+### Local Development Without OAuth2
+
+**⚠️ SECURITY WARNING**: The `single-auth` profile is intended **ONLY** for local development and testing environments. **DO NOT** use this profile in production, staging, or any environment accessible from the internet. The single-auth profile provides simple admin authentication and should never be used where security is a concern.
+
+OSMT supports a `single-auth` profile that allows local development and testing with simple admin username/password authentication. This is ideal for:
+
+- Quick local setup without OAuth provider configuration
+- Testing authorization logic with admin role
+- CI/CD pipelines without OAuth secrets
+- Development when OAuth provider is unavailable
+
+**How it works:**
+
+- OSMT automatically detects when OAuth credentials are missing and defaults to the `single-auth` profile
+- Admin credentials can be configured via properties or environment variables (defaults to `admin`/`admin`)
+- Authentication supports HTTP Basic Auth or Bearer tokens from the `/api/auth/login` endpoint
+- Authenticated users receive `ROLE_Osmt_Admin` and authorization rules are enforced using the same role-based logic as OAuth2 mode
+
+**Starting with single-auth mode (default for local development):**
+
+1. Leave OAuth values as `xxxxxx` in your environment files, or omit them entirely
+2. Start OSMT:
+
+ ```bash
+ ./osmt_cli.sh -s
+ ```
+
+ OSMT will automatically detect missing OAuth credentials and use the `single-auth` profile with `dev` profile
+
+3. **Authenticating with single-auth:**
+
+ - **Via HTTP Basic Auth:**
+
+ ```bash
+ curl -u admin:admin http://localhost:8080/api/v3/skills
+ ```
+
+ - **Via login endpoint (get Bearer token):**
+
+ ```bash
+ curl -X POST http://localhost:8080/api/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin"}'
+
+ # Use the returned token:
+ curl -H "Authorization: Bearer " http://localhost:8080/api/v3/skills
+ ```
+
+4. **UI development with single-auth:**
+ - Start backend: `./osmt_cli.sh -s` (auto-detects single-auth)
+ - Start UI: `cd ui && npm start`
+ - Access UI at `http://localhost:4200`
+ - Login with admin credentials (default: `admin`/`admin`)
+
+**Configuration:**
+
+- Admin username: Set via `app.single-auth.admin-username` property or `SINGLE_AUTH_ADMIN_USERNAME` environment variable (default: `admin`)
+- Admin password: Set via `app.single-auth.admin-password` property or `SINGLE_AUTH_ADMIN_PASSWORD` environment variable (default: `admin`)
+- See `api/src/main/resources/config/application-single-auth.properties` for all configuration options
+
+**Note:** The `single-auth` profile is automatically used when OAuth credentials are missing. The CLI script (`./osmt_cli.sh -s`) uses `dev` and `single-auth` profiles by default for local development.
+
### Environment Files for Development and API Tests Stacks
-There are many ways to provide environment values to a Spring application. That said, you should never push secrets to GitHub, so you should never store secrets in source code. The OSMT project is configured to git ignore files named `osmt*.env`, and we recommend you follow this approach.
-The OSMT source code includes example environment files for the Development configuration (`api/osmt-dev-stack.env.example`) and API Tests configuration (`test/api/osmt-apitest.env.example`). Running `./osmt_cli.sh -i` will create env files for you, but you will need to replace the 'xxxxxx' values with your OAUTH2/OIDC values, following the guidance in the [OAuth2 and Okta Configuration](README.md#oauth2-and-okta-configuration) section.
+
+There are many ways to provide environment values to a Spring application. That said, you should never push secrets to GitHub, so you should never store secrets in source code. The OSMT project is configured to git ignore files named `osmt*.env`, and we recommend you follow this approach.
+The OSMT source code includes example environment files for the Development configuration (`api/osmt-dev-stack.env.example`) and API Tests configuration (`test/osmt-apitest.env.example`). Running `./osmt_cli.sh -i` will create env files for you.
+
+- **For OAuth2 mode**: Replace the 'xxxxxx' values with your OAUTH2/OIDC values, following the guidance in the [OAuth2 and Okta Configuration](README.md#oauth2-and-okta-configuration) section.
+- **For single-auth mode**: You can leave the 'xxxxxx' values as-is. OSMT will automatically use the `single-auth` profile when OAuth credentials are missing.
#### Alternate approaches for Providing Environment Variables
-* Provide these OAuth2 values as program arguments when starting your Spring Boot app (`-Dokta.oauth2.clientId="123456qwerty"`).
-* Set environment variables by supplying json
- * For MacOS & Linux, replace values and run below command.
+
+- Provide these OAuth2 values as program arguments when starting your Spring Boot app (`-Dokta.oauth2.clientId="123456qwerty"`).
+- Set environment variables by supplying json
+ - For MacOS & Linux, replace values and run below command.
```
export SPRING_APPLICATION_JSON="{\"OAUTH_ISSUER\":\"\",\"OAUTH_CLIENTID\":\"\", \"OAUTH_CLIENTSECRET\":\"\",\"OAUTH_AUDIENCE\":\"\"}"
```
- * For Windows, replace values and run below command.
+ - For Windows, replace values and run below command.
```
set SPRING_APPLICATION_JSON="{\"OAUTH_ISSUER\":\"\",\"OAUTH_CLIENTID\":\"\", \"OAUTH_CLIENTSECRET\":\"\",\"OAUTH_AUDIENCE\":\"\"}"
```
-### Post-installation (BLS, O*NET, etc)
+### Post-installation (BLS, O\*NET, etc)
+
For Development configuration, you can import the metadata with this command:
+
```
osmt_cli.sh -m
```
-* Keep in mind that removing Docker volumes will also remove this metadata. For more information, see the section for [Importing Data](api/README.md#importing-data) in the API README file.
+
+- Keep in mind that removing Docker volumes will also remove this metadata. For more information, see the section for [Importing Data](api/README.md#importing-data) in the API README file.
### Role-based Access in OSMT
-* Role-based access is **enabled** by default for the Angular UI and Spring REST API.
+
+- Role-based access is **enabled** by default for the Angular UI and Spring REST API.
OSMT optionally supports role-based access, with these roles:
+
- **Admin**: an OSMT user with an admin role can change RSDs and Collections in any way.
- **Curator**: an OSMT user with a curator role can create RSDs and Collections. This role is for someone who would upload and create RSDs and Collections.
- **Viewer**: an OSMT user with a viewer role is a logged-in user who can not make modifications to RSDs or Collections.
-| Behavior | Curator Role | Admin Role |
-|-----------------------------------------|:------------:|:----------:|
-| Update Skills | | X |
-| Archive and Unarchive Skills | X | X |
-| Create Skills | X | X |
-| Publish Skills | | X |
-| Update Collection | | X |
-| Archive and Unarchive Collection | X | X |
-| Create Collection | X | X |
-| Publish Collection | | X |
-| Delete Collection | | X |
-| Add/Remove Skills to/from a Collection | | X |
-| Library Export | | X |
-| Export Draft Collection | | X |
-| Workspace Access | X | X |
+| Behavior | Curator Role | Admin Role |
+|----------------------------------------|:------------:|:----------:|
+| Update Skills | | X |
+| Archive and Unarchive Skills | X | X |
+| Create Skills | X | X |
+| Publish Skills | | X |
+| Update Collection | | X |
+| Archive and Unarchive Collection | X | X |
+| Create Collection | X | X |
+| Publish Collection | | X |
+| Delete Collection | | X |
+| Add/Remove Skills to/from a Collection | | X |
+| Library Export | | X |
+| Export Draft Collection | | X |
+| Workspace Access | X | X |
#### Configuration in OSMT Code
+
By default, OSMT is configured with roles enabled. If you want to disable roles in OSMT, apply these changes:
In Angular UI: In [`auth-roles.ts`](ui/src/app/auth/auth-roles.ts) file, configure these values:
+
```
export const ENABLE_ROLES = false
```
In Spring REST API: In [`application.properties`](api/src/main/resources/config/application.properties) file, configure these values:
+
```
# Roles settings
app.enableRoles=false
```
-* NOTE: if app.enableRoles=false, all authenticated endpoints will be accessible by any authenticated user.
-* You can use these role values, or you can provide your own based on your own authorization tooling. For Okta, you will need to use the uppercase `ROLE_` prefix on your role.
-* `read` is a scope, not a role. This is for machine-to-machine access, rather than for authenticated OSMT users.
+- NOTE: if app.enableRoles=false, all authenticated endpoints will be accessible by any authenticated user.
+- You can use these role values, or you can provide your own based on your own authorization tooling. For Okta, you will need to use the uppercase `ROLE_` prefix on your role.
+- `read` is a scope, not a role. This is for machine-to-machine access, rather than for authenticated OSMT users.
#### Configuration in Okta
+
In Okta, navigate to "Directory", then "Groups". You will need to create 3 groups with names that match your `osmt.security.role` values in `application.properties`.
[
](docs/int/okta/okta_groups_for_osmt_roles.png)
You can assign your Okta user accounts to these groups. In Okta, navigate to "Security", then "API". Open the "default" API, and navigate to the "Claims" tab.
-* Add claim for "Access"
+- Add claim for "Access"
[
](docs/int/okta/okta_claims_access.png)
-* Add a claim for "ID Token"
+- Add a claim for "ID Token"
[
](docs/int/okta/okta_claims_id.png)
-* Your end result should look like this:
+- Your end result should look like this:
[
](docs/int/okta/okta_claims.png)
@@ -264,6 +402,7 @@ From the Scopes tab, add a scope with name that matches osmt.security.scope.read
[
](docs/int/okta/okta_scopes.png)
## How to get help
+
This project includes [./api/HELP.md](api/HELP.md), with links to relevant references and tutorials.
OMST is an open source project, and is supported by its community. Please look to the Discussion boards and Wiki on GitHub. Please see the [CONTRIBUTING.md](CONTRIBUTING.md) document for additional context.
diff --git a/api/.editorconfig b/api/.editorconfig
new file mode 100644
index 000000000..472e93a54
--- /dev/null
+++ b/api/.editorconfig
@@ -0,0 +1,10 @@
+# EditorConfig for Kotlin files in api module
+# ktlint reads this file for formatting rules
+
+root = false
+
+[*.{kt,kts}]
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+max_line_length = 120
diff --git a/api/Dockerfile b/api/Dockerfile
new file mode 100644
index 000000000..9a80dccc7
--- /dev/null
+++ b/api/Dockerfile
@@ -0,0 +1,79 @@
+FROM oraclelinux:9 AS base
+
+ENV USER=osmt
+ENV BASE_DIR=/opt/${USER}
+ENV JAVA_HOME=/etc/alternatives/jre
+
+# Install EPEL / Useful packages /
+RUN /usr/bin/yum install -y epel-release \
+ && /usr/bin/yum update -y \
+ && /usr/bin/yum remove -y java-1.8.0-openjdk* \
+ && /usr/bin/yum install -y curl java-21-openjdk
+
+# Add in configuration files
+ADD ./api/docker/etc /etc
+
+# Set a DNS lookup TTL to 10 seconds
+RUN sed -i 's/#networkaddress.cache.ttl=-1/networkaddress.cache.ttl=10/' ${JAVA_HOME}/conf/security/java.security
+
+# Create the osmt non-root user
+RUN /usr/sbin/useradd -r -d ${BASE_DIR} -s /bin/bash ${USER} -k /etc/skel -m -U \
+ && mkdir -p ${BASE_DIR}/bin ${BASE_DIR}/build ${BASE_DIR}/logs ${BASE_DIR}/etc \
+ && chown -R ${USER}:${USER} ${BASE_DIR}
+
+
+FROM base AS build
+
+ENV USER=osmt
+ENV BASE_DIR=/opt/${USER}
+ENV JAVA_HOME=/etc/alternatives/jre
+
+# Set Maven environment variables
+ENV M2_VERSION=3.9.6
+ENV M2_HOME=/usr/local/maven
+ENV PATH=${M2_HOME}/bin:${PATH}
+
+# Install compile-time extra packages
+RUN /usr/bin/yum install -y java-21-openjdk-devel wget make gcc g++ chromium
+
+# Download / Install Maven
+ADD https://dlcdn.apache.org/maven/maven-3/${M2_VERSION}/binaries/apache-maven-${M2_VERSION}-bin.tar.gz /usr/share/src/
+
+WORKDIR /usr/share/src
+
+RUN tar -xf apache-maven-${M2_VERSION}-bin.tar.gz \
+ && mv apache-maven-${M2_VERSION} ${M2_HOME}/
+
+COPY . ${BASE_DIR}
+
+# Descend into the API directory
+WORKDIR ${BASE_DIR}/api
+
+COPY --from=osmt-ui:latest --chown=${USER}:${USER} /usr/share/nginx/html/* ./src/main/resources/ui/
+
+# Compile the API application
+RUN --mount=type=cache,id=mvncache,target=/root/.m2 \
+ mvn -B -P dockerfile-build -DskipTests package
+
+FROM build AS test
+
+# Set Maven and Test environment variables
+ENV M2_HOME=/usr/local/maven
+ENV PATH=${M2_HOME}/bin:${PATH}
+ENV CHROME_BIN=/usr/bin/chromium-browser
+
+# Test the API application
+RUN --mount=type=cache,id=mvncache,target=${BASE_DIR}/.m2 \
+ mvn -B -P dockerfile-build test
+
+FROM base AS deploy
+
+LABEL Maintainer="OSMT Contributors"
+
+ENV USER=osmt
+ENV BASE_DIR=/opt/${USER}
+
+# Become the osmt user
+USER ${USER}
+
+COPY --from=build --chown=${USER}:${USER} ${BASE_DIR}/api/target/*.jar ${BASE_DIR}/bin/app.jar
diff --git a/api/README.md b/api/README.md
index fdafe14b6..a7a627b79 100644
--- a/api/README.md
+++ b/api/README.md
@@ -1,95 +1,139 @@
# OSMT API
+
This Maven module represents the Spring Boot API application.
## Spring Boot Configuration and Profiles
-Spring Boot uses profiles to manage its runtime configuration. While these can be provided in different ways, osmt_cli.sh uses `-D` to set a `spring-boot.run.profiles` system property in the JVM. A typical OSMT profile list will look like `dev,apiserver,oauth2-okta`. In OSMT, this list of profiles informs which property files are loaded, and which Spring Boot components are run.
-* Property files -- If a profile from the active profiles list (`dev`) matches a property file (`application-dev.properties`), then that property file is loaded. Spring Boot's property files are located in `./api/src/main/resources/config/`.
-* Spring Boot components -- When Spring Boot starts, it scans for classes with a `@Component` annotation. If a profile from the active profiles list (`apiserver`) matches a @Profile annotation in a @Component class (`@Profile("apiserver")`), then that class is loaded.
+Spring Boot uses profiles to manage its runtime configuration. While these can be provided in different ways, osmt_cli.sh uses `-D` to set a `spring-boot.run.profiles` system property in the JVM. A typical OSMT profile list will look like `dev,apiserver,oauth2`. In OSMT, this list of profiles informs which property files are loaded, and which Spring Boot components are run.
+
+- Property files -- If a profile from the active profiles list (`dev`) matches a property file (`application-dev.properties`), then that property file is loaded. Spring Boot's property files are located in `./api/src/main/resources/config/`.
+- Spring Boot components -- When Spring Boot starts, it scans for classes with a `@Component` annotation. If a profile from the active profiles list (`apiserver`) matches a @Profile annotation in a @Component class (`@Profile("apiserver")`), then that class is loaded.
The Spring profiles in OSMT can be conceptually grouped as:
-* Configuration Profiles - these contextualize an SDLC environment (i.e., the `dev` profile for local development). If no Configuration Profile is provided, the values in application.properties will be used without override.
- | Configuration Profile | Properties file |
- | -------------------------- | -------------------------- |
- | (none) | application.properties |
- | dev | application-dev.properties |
- | staging | application-staging.properties |
- | review | application-review.properties |
- | test | application-test.properties |
+- Configuration Profiles - these contextualize an SDLC environment (i.e., the `dev` profile for local development). If no Configuration Profile is provided, the values in application.properties will be used without override.
+
+ | Configuration Profile | Properties file |
+ | --------------------- | ------------------------------ |
+ | (none) | application.properties |
+ | dev | application-dev.properties |
+ | staging | application-staging.properties |
+ | review | application-review.properties |
+ | test | application-test.properties |
-* Component profiles - these active certain Spring Boot `@Component`(s). See notes on "Security" and "Application" profiles.
+- Component profiles - these active certain Spring Boot `@Component`(s). See notes on "Security" and "Application" profiles.
- | Security Component Profile | |
- | -------------------------- | -------------------------- |
- | oauth2-okta | Includes required configuration for OAuth2 OIDC with Okta |
+ | Security Component Profile | |
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | oauth2 | OAuth2 OIDC (Okta, Google, or custom providers via application-oauth2.properties) |
+ | single-auth | Enables local development with simple admin authentication. Admin credentials provided via environment variables. Authorization rules are still enforced. |
- | Application Component Profile | |
- | -------------------------- | -------------------------- |
- | apiserver | Starts the API server. API endpoints started with this profile will also require a
Security Component Profile (see `oauth2-okta`, above) |
- | import | Runs the batch import process, expects `--csv=` argument and `--import-type=` argument.
This process terminates when complete, and does not expose API endpoints; no Security profile is needed. |
- | reindex | Runs the Elasticsearch re-index process.
This process terminates when complete, and does not expose API endpoints; no Security profile is needed. |
+ | Application Component Profile | |
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | apiserver | Starts the API server. API endpoints started with this profile will also require a
Security Component Profile (see `oauth2` or `single-auth`, above) |
+ | import | Runs the batch import process, expects `--csv=` argument and `--import-type=` argument.
This process terminates when complete, and does not expose API endpoints; no Security profile is needed. |
+ | reindex | Runs the Elasticsearch re-index process.
This process terminates when complete, and does not expose API endpoints; no Security profile is needed. |
## Running from the Command Line
+
See [Using the OSMT development utility](../README.md#using-the-osmt-cli-utility-osmt_clish) in the project [README.md](../README.md) for using `osmt_cli.sh` to start and stop the Development Docker services and the Spring API application. `osmt_cli.sh` automatically sources the environment variables from `api/osmt-dev-stack.env`.
-* You are not required to use the `osmt_cli.sh` utility. Many will prefer to run `mvn` and `java -jar` commands against the jars in the api/target directory _(this workflow assumes you know how to use `mvn clean package` to create jars)_. Examples are given below. You will probably need to use a configuration profile (i.e., `dev`) and at least one application component profile (i.e., `apiserver`).
-* To override specific properties with JVM arguments when developing with Maven, pass the JVM arguments as the value to `-Dspring-boot.run.jvmArguments=`
-* Depending on the configuration you use, you may need to source the environment variables from `api/osmt-dev-stack.env`.
- * This command will help source an environment file into a shell session (`omst_dev.sh -s` does this for you automatically:
+- You are not required to use the `osmt_cli.sh` utility. Many will prefer to run `mvn` and `java -jar` commands against the jars in the api/target directory _(this workflow assumes you know how to use `mvn clean package` to create jars)_. Examples are given below. You will probably need to use a configuration profile (i.e., `dev`) and at least one application component profile (i.e., `apiserver`).
+- To override specific properties with JVM arguments when developing with Maven, pass the JVM arguments as the value to `-Dspring-boot.run.jvmArguments=`
+- Depending on the configuration you use, you may need to source the environment variables from `api/osmt-dev-stack.env`.
+ - This command will help source an environment file into a shell session (`omst_dev.sh -s` does this for you automatically:
```
set -o allexport; source api/osmt-dev-stack.env; set +o allexport;
```
Examples:
-* Using Maven to start the API server overriding the `spring.flyway.enabled` property:
- ```
- mvn -Dspring-boot.run.profiles=dev,apiserver,oauth2-okta \
- -Dspring-boot.run.jvmArguments="-Dspring.flyway.enabled=false" \
- spring-boot:run
- ```
-* Using Java to import BLS metadata
- ```
- java -jar -Dspring.profiles.active=dev,import \
- api/target/osmt-api-.jar --csv=path/to/csv --import-type=bls
- ```
-## OAuth2
-An example profile and Spring Boot components (edu.wgu.osmt.security.SecurityConfig) are provided to support OAuth2 with Okta. To use a different provider, create an additional profile-scoped Spring @Component that implements `org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter`, and activate that security profile. Additional Spring Boot components may also be required to support the chosen provider. See [Okta Configuration](../README.md#oauth2-and-okta-configuration) in the project [README](../README.md) for more details.
-* It is possible that you will need to exclude the `com.okta.spring.okta-spring-boot-starter` Maven dependency.
+- Using Maven to start the API server overriding the `spring.flyway.enabled` property:
+ ```
+ mvn -Dspring-boot.run.profiles=dev,apiserver,oauth2 \
+ -Dspring-boot.run.jvmArguments="-Dspring.flyway.enabled=false" \
+ spring-boot:run
+ ```
+- Using Java to import BLS metadata
+ ```
+ java -jar -Dspring.profiles.active=dev,import \
+ api/target/osmt-api-.jar --csv=path/to/csv --import-type=bls
+ ```
+
+## Authentication and Security Profiles
+
+OSMT supports two security profiles:
+
+### OAuth2 with Okta
+
+Spring Boot components (edu.wgu.osmt.security.SecurityConfig) support OAuth2 with Okta, Google, or custom providers. Add providers via application-oauth2.properties; no code changes required. See [Okta Configuration](../README.md#oauth2-and-okta-configuration) in the project [README](../README.md) for more details.
+
+- It is possible that you will need to exclude the `com.okta.spring.okta-spring-boot-starter` Maven dependency.
+
+### NoAuth Profile
+
+**⚠️ SECURITY WARNING**: The `single-auth` profile is intended **ONLY** for local development and testing. **DO NOT** use in production environments as it provides simple admin authentication.
+
+The `single-auth` profile allows local development and testing with simple admin authentication. Admin credentials are provided via:
+
+- HTTP header: `X-Test-Role: ROLE_Osmt_Admin`
+- Query parameter: `?testRole=ROLE_Osmt_Admin`
+- Environment variable: `TEST_ROLE=ROLE_Osmt_Admin`
+- Default: `app.test.defaultRole` property (defaults to `ROLE_Osmt_Admin`)
+
+Authorization rules are still enforced using the same role-based logic as OAuth2 mode. See [Local Development Without OAuth2](../README.md#local-development-without-oauth2) in the project README for more details.
+
+Example usage:
+
+```bash
+# Start with single-auth profile
+mvn -Dspring-boot.run.profiles=dev,apiserver,single-auth spring-boot:run
+
+# Test with different roles
+curl -H "X-Test-Role: ROLE_Osmt_Admin" http://localhost:8080/api/v3/skills
+```
## Database Configurations
+
This project uses [FlywayDb](https://flywaydb.org/). SQL Migrations can be placed in `./api/src/main/resources/db/migration/`.
Scripts in this folder will be automatically processed when the app is started with the appropriate `application.properties` settings in `spring.flyway.*`.
By default, only `test` and `dev` configuration profiles will automatically run migrations. To enable migrations for other environments, i.e. a single production server, start the server with this JVM argument: `-Dspring.flyway.enabled=true`.
## Allowing anonymous API to search and list endpoints for published skills and collections
+
This feature is enabled by default. These settings are in application.properties
-* `app.allowPublicSearching=true`
-* `app.allowPublicLists=true`
+
+- `app.allowPublicSearching=true`
+- `app.allowPublicLists=true`
+- `app.publicKeywordLimit=1000` — max skills scanned for unauthenticated keyword suggestions
## Caching and Rate Limiting with bucket4j
+
OSMT uses Bucket4j for caching and rate limiting on the API. You can learn more about this [here](https://www.baeldung.com/spring-bucket4j). These features are enabled by including "bucket4j" as a run profile, which pull in [./src/main/resources/config/application-bucket4j.properties](./src/main/resources/config/application-bucket4j.properties). All of these properties can be adjusted at runtime as Spring application properties.
## Importing Data
-You can use the import component profile to import RSDs, and BLS/O*NET job code metadata. If running from the command line, your active Spring profiles will look like this:
-```-Dspring.profiles.active=dev,import```
+
+You can use the import component profile to import RSDs, and BLS/O\*NET job code metadata. If running from the command line, your active Spring profiles will look like this:
+`-Dspring.profiles.active=dev,import`
Imports via the command line require these 2 arguments:
-| Command Line Arguments | Values |
-| ----------- | ----------- |
-| --import-type | RSDs - `batchskill`
BLS - `bls`
O*NET - `onet`|
-| --csv | a valid path to a CSV file |
+| Command Line Arguments | Values |
+| ---------------------- | ----------------------------------------------------- |
+| --import-type | RSDs - `batchskill`
BLS - `bls`
O\*NET - `onet` |
+| --csv | a valid path to a CSV file |
So commands will look something like this:
+
```
java -jar -Dspring.profiles.active=dev,import \
api/target/osmt-api-.jar \
--csv=path/to/csv --import-type=batchskill
```
+
or
+
```
mvn -Dspring-boot.run.profiles=dev,import \
-Dspring-boot.run.arguments="--import-type=bls,--csv=path/to/csv" \
@@ -97,50 +141,62 @@ or
```
Please see the `import` folder in the project root for sample files. The general import sequence should be:
-1. RSDs (```--import-type=batchskill```)
-2. BLS (```--import-type=bls```)
-3. O*NET (```--import-type=onet```)
+
+1. RSDs (`--import-type=batchskill`)
+2. BLS (`--import-type=bls`)
+3. O\*NET (`--import-type=onet`)
4. Reindex Elasticsearch
### Importing BLS codes:
+
BLS codes will not be duplicated if imported multiple times
-* Note - BLS codes should be imported before O*NET codes
+
+- Note - BLS codes should be imported before O\*NET codes
+
1. Download BLS codes in Excel format from [https://www.bls.gov/soc/2018/#materials]("https://www.bls.gov/soc/2018/#materials")
-2. Remove the content before header row.
+2. Remove the content before header row.
3. Convert Excel to CSV format
4. Import the CSV with the following command:
- ```
- java -jar -Dspring.profiles.active=dev,import api/target/osmt-api-.jar --csv=path/to/bls_csv --import-type=bls
- ```
+ ```
+ java -jar -Dspring.profiles.active=dev,import api/target/osmt-api-.jar --csv=path/to/bls_csv --import-type=bls
+ ```
-### Importing O*NET codes
-O*NET codes will not be duplicated if imported multiple times
-* Note - BLS codes should be imported before O*NET codes
-1. Download O*NET `Occupation Data` in Excel format from [https://www.onetcenter.org/database.html#occ]("https://www.onetcenter.org/database.html#occ")
+### Importing O\*NET codes
+
+O\*NET codes will not be duplicated if imported multiple times
+
+- Note - BLS codes should be imported before O\*NET codes
+
+1. Download O\*NET `Occupation Data` in Excel format from [https://www.onetcenter.org/database.html#occ]("https://www.onetcenter.org/database.html#occ")
2. Convert Excel to CSV format
3. Import the CSV with the following command:
- ```
- java -jar -Dspring.profiles.active=dev,import api/target/osmt-api-.jar --csv=path/to/onet_csv --import-type=onet
- ```
+ ```
+ java -jar -Dspring.profiles.active=dev,import api/target/osmt-api-.jar --csv=path/to/onet_csv --import-type=onet
+ ```
## Elasticsearch indexing
+
After importing, it is necessary to run Spring Boot with the `reindex` profile to generate Elasticsearch index mappings and documents. The `` in the following examples can be `dev`,`review` or omitted for production.
Via the compiled jar:
+
+```
+java -Dspring.profiles.active=,reindex -jar api/target/osmt-api-.jar
```
-java -Dspring.profiles.active=,reindex -jar api/target/osmt-api-.jar
-```
Via mvn:
+
```
mvn -DSpring.profiles.active=,reindex spring-boot:run
```
### Reindex after changes to Elasticsearch `@Document` index classes
-If changes are made to @Document annotated classes, the indexes need to be deleted and re-indexed.
-* Delete all of the indices in Elasticsearch by executing the following:
- ```
- curl -X DELETE "http://:/*"
- ```
- Where `host` and `port` represent the Elasticsearch server you are targeting, e.g. `localhost:9200`
-* Run the reindex command from above to generate the new mappings and documents
+
+If changes are made to @Document annotated classes, the indexes need to be deleted and re-indexed.
+
+- Delete all of the indices in Elasticsearch by executing the following:
+ ```
+ curl -X DELETE "http://:/*"
+ ```
+ Where `host` and `port` represent the Elasticsearch server you are targeting, e.g. `localhost:9200`
+- Run the reindex command from above to generate the new mappings and documents
diff --git a/api/docker/bin/docker_entrypoint.sh b/api/docker/bin/docker_entrypoint.sh
new file mode 100755
index 000000000..281e4f30d
--- /dev/null
+++ b/api/docker/bin/docker_entrypoint.sh
@@ -0,0 +1,364 @@
+#!/bin/bash
+
+set -eu
+
+declare BASE_DOMAIN="${BASE_DOMAIN:-}"
+declare ENVIRONMENT="${ENVIRONMENT:-}"
+declare DB_URI="${DB_URI:-}"
+declare REDIS_URI="${REDIS_URI:-}"
+declare ELASTICSEARCH_URI="${ELASTICSEARCH_URI:-}"
+declare OAUTH_ISSUER="${OAUTH_ISSUER:-}"
+declare OAUTH_CLIENTID="${OAUTH_CLIENTID:-}"
+declare OAUTH_CLIENTSECRET="${OAUTH_CLIENTSECRET:-}"
+declare OAUTH_AUDIENCE="${OAUTH_AUDIENCE:-}"
+declare OAUTH_GOOGLE_CLIENT_ID="${OAUTH_GOOGLE_CLIENT_ID:-}"
+declare OAUTH_GOOGLE_CLIENT_SECRET="${OAUTH_GOOGLE_CLIENT_SECRET:-}"
+declare MIGRATIONS_ENABLED="${MIGRATIONS_ENABLED:-}"
+declare SKIP_METADATA_IMPORT="${SKIP_METADATA_IMPORT:-}"
+declare REINDEX_ELASTICSEARCH="${REINDEX_ELASTICSEARCH:-}"
+declare FRONTEND_URL="${FRONTEND_URL:-}"
+declare OSMT_JAR
+
+function echo_info() {
+ echo "[docker_entrypoint.sh]: $*"
+}
+
+function echo_err() {
+ echo "[docker_entrypoint.sh] ERROR: $*" 1>&2
+}
+
+function echo_debug() {
+ if [[ "${DEBUG:-}" == "1" ]] || [[ "${DEBUG:-}" == "true" ]]; then
+ echo "[docker_entrypoint.sh] DEBUG: $*" >&2
+ fi
+}
+
+function validate() {
+ echo_info "Validating required and optional environment variables"
+ echo_debug "ENVIRONMENT variable value: '${ENVIRONMENT}'"
+ echo_debug "ENVIRONMENT variable length: ${#ENVIRONMENT}"
+ echo_debug "SPRING_PROFILES_ACTIVE variable value: '${SPRING_PROFILES_ACTIVE:-}'"
+ if [[ -n "${SPRING_PROFILES_ACTIVE:-}" ]]; then
+ echo_debug "SPRING_PROFILES_ACTIVE variable length: ${#SPRING_PROFILES_ACTIVE}"
+ else
+ echo_debug "SPRING_PROFILES_ACTIVE variable length: 0 (not set)"
+ fi
+
+ local -i missing_args=0
+
+ local -a required_args
+ required_args=(
+ "BASE_DOMAIN"
+ "ENVIRONMENT"
+ "DB_URI"
+ "REDIS_URI"
+ "ELASTICSEARCH_URI"
+ )
+
+ for arg in "${required_args[@]}"; do
+ echo_debug "Checking required arg: ${arg}='${!arg:-}'"
+ if [[ -z ${!arg} ]]; then
+ missing_args++
+ echo_err "Missing environment ${arg}"
+ fi
+ done
+
+ # OAuth variables are optional - if missing, single-auth profile will be used
+ # Note: This script runs in Docker and cannot source common.sh, so it implements
+ # the same profile detection logic inline to ensure consistency.
+ # IMPORTANT: This logic MUST be kept in sync with detect_security_profile() in bin/lib/common.sh
+ local -i has_oauth_okta=0
+ if [[ -n "${OAUTH_ISSUER:-}" ]] && [[ -n "${OAUTH_CLIENTID:-}" ]] &&
+ [[ -n "${OAUTH_CLIENTSECRET:-}" ]] && [[ -n "${OAUTH_AUDIENCE:-}" ]]; then
+ has_oauth_okta=1
+ fi
+ local -i has_oauth_google=0
+ if [[ -n "${OAUTH_GOOGLE_CLIENT_ID:-}" ]] &&
+ [[ -n "${OAUTH_GOOGLE_CLIENT_SECRET:-}" ]]; then
+ has_oauth_google=1
+ fi
+
+ echo_debug "OAuth check: has_oauth_okta=${has_oauth_okta}, has_oauth_google=${has_oauth_google}"
+ echo_debug "ENVIRONMENT before OAuth logic: '${ENVIRONMENT}'"
+
+ if [[ ${has_oauth_okta} -eq 1 ]] || [[ ${has_oauth_google} -eq 1 ]]; then
+ echo_info "OAuth credentials provided - will use oauth2 profile"
+ if [[ "${ENVIRONMENT}" != *"oauth2"* ]]; then
+ ENVIRONMENT="${ENVIRONMENT},oauth2"
+ echo_debug "ENVIRONMENT after appending oauth2: '${ENVIRONMENT}'"
+ fi
+ fi
+ # Add single-auth when no OAuth, or when ENABLE_SINGLE_AUTH=true (staging)
+ if [[ ${has_oauth_okta} -eq 0 ]] && [[ ${has_oauth_google} -eq 0 ]]; then
+ echo_info "OAuth credentials not provided - will use single-auth profile"
+ if [[ "${ENVIRONMENT}" != *"single-auth"* ]]; then
+ ENVIRONMENT="${ENVIRONMENT},single-auth"
+ echo_info "Updated ENVIRONMENT to: ${ENVIRONMENT}"
+ fi
+ elif [[ "${ENABLE_SINGLE_AUTH:-}" == "true" ]] &&
+ [[ "${ENVIRONMENT}" != *"single-auth"* ]]; then
+ ENVIRONMENT="${ENVIRONMENT},single-auth"
+ echo_info "ENABLE_SINGLE_AUTH=true - adding single-auth for staging"
+ fi
+
+ echo_debug "ENVIRONMENT after validate(): '${ENVIRONMENT}'"
+
+ # optional args
+ if [[ -z "${MIGRATIONS_ENABLED}" ]]; then
+ MIGRATIONS_ENABLED=false
+ echo_info "Missing environment 'MIGRATIONS_ENABLED'"
+ echo_info " Defaulting to MIGRATIONS_ENABLED=${MIGRATIONS_ENABLED}"
+ fi
+
+ if [[ -z "${REINDEX_ELASTICSEARCH}" ]]; then
+ REINDEX_ELASTICSEARCH=false
+ echo_info "Missing environment 'REINDEX_ELASTICSEARCH'"
+ echo_info " Defaulting to REINDEX_ELASTICSEARCH=${REINDEX_ELASTICSEARCH}"
+ fi
+
+ if [[ -z "${SKIP_METADATA_IMPORT}" ]]; then
+ SKIP_METADATA_IMPORT=false
+ echo_info "Missing environment 'SKIP_METADATA_IMPORT'"
+ echo_info " Defaulting to SKIP_METADATA_IMPORT=${SKIP_METADATA_IMPORT}"
+ fi
+
+ if [[ -z "${FRONTEND_URL}" ]]; then
+ FRONTEND_URL="http://${BASE_DOMAIN}"
+ echo_info "Missing environment 'FRONTEND_URL'"
+ echo_info " Defaulting to FRONTEND_URL=${FRONTEND_URL}"
+ fi
+
+ if [[ ${missing_args} != 0 ]]; then
+ echo_err "Missing ${missing_args} shell variable(s), exiting.."
+ exit 135
+ fi
+}
+
+function build_reindex_profile_string() {
+ # accept the $ENVIRONMENT env var, i.e. "test,apiserver,oauth2"
+ declare env_arg=${1}
+
+ echo "reindex,$(get_config_profile_from_env "${env_arg}" 2>/dev/null)"
+}
+
+function get_config_profile_from_env() {
+ # accept the $ENVIRONMENT env var, i.e. "test,apiserver,oauth2"
+ declare env_arg=${1}
+
+ echo_debug "get_config_profile_from_env called with: '${env_arg}'"
+
+ # If $ENVIRONMENT contains the config profile from one of these Spring application profiles,
+ # then append it to the reindex profile string
+ # Valid OSMT Spring Boot profiles: dev, test, review, staging
+ declare -ar config_profile_list=("dev" "test" "review" "staging")
+
+ for config_profile in "${config_profile_list[@]}"; do
+ if grep -q "${config_profile}" <<<"${env_arg}"; then
+ echo_debug "Found config profile: ${config_profile}"
+ echo "${config_profile}"
+ return
+ fi
+ done
+
+ echo_debug "No config profile found in: '${env_arg}'"
+}
+
+function import_metadata() {
+ if [[ "${SKIP_METADATA_IMPORT}" == "true" ]]; then
+ echo_info "Skipping BLS and O*NET metadata import"
+ else
+ echo_info "Importing BLS metadata"
+ local config_profile
+ config_profile=$(get_config_profile_from_env "${ENVIRONMENT}" 2>/dev/null)
+ echo_debug "Using config profile for import: '${config_profile}'"
+ local java_cmd="/bin/java -jar
+ -Dspring.profiles.active=${config_profile},import
+ -Ddb.uri=${DB_URI}
+ -Dspring.flyway.enabled=${MIGRATIONS_ENABLED}
+ /opt/osmt/bin/osmt.jar
+ --csv=/opt/osmt/import/BLS-Import.csv
+ --import-type=bls"
+
+ echo_debug "BLS import java command: ${java_cmd}"
+ run_cmd_with_retry "${java_cmd}"
+
+ echo_info "Importing O*NET metadata"
+ config_profile=$(get_config_profile_from_env "${ENVIRONMENT}")
+ echo_debug "Using config profile for import: '${config_profile}'"
+ local java_cmd="/bin/java -jar
+ -Dspring.profiles.active=${config_profile},import
+ -Ddb.uri=${DB_URI}
+ -Dspring.flyway.enabled=${MIGRATIONS_ENABLED}
+ /opt/osmt/bin/osmt.jar
+ --csv=/opt/osmt/import/oNet-Import.csv
+ --import-type=onet"
+
+ echo_debug "O*NET import java command: ${java_cmd}"
+ run_cmd_with_retry "${java_cmd}"
+ fi
+}
+
+function reindex_elasticsearch() {
+ # The containerized Spring app needs an initial ElasticSearch index, or it returns 500s.
+ if [[ "${REINDEX_ELASTICSEARCH}" == "true" ]]; then
+ local reindex_profile_string
+ reindex_profile_string="reindex,$(get_config_profile_from_env "${ENVIRONMENT}" 2>/dev/null)"
+ echo_debug "Reindex profile string: '${reindex_profile_string}'"
+
+ echo_info "Building initial index in OSMT ElasticSearch using ${reindex_profile_string} Spring profiles..."
+ java_cmd="/bin/java
+ -Dspring.profiles.active=${reindex_profile_string}
+ -Dredis.uri=${REDIS_URI}
+ -Ddb.uri=${DB_URI}
+ -Des.uri=${ELASTICSEARCH_URI}"
+
+ # Add Elasticsearch credentials if provided (for fine-grained access control)
+ echo_debug "Checking Elasticsearch credentials for reindex: ELASTICSEARCH_USERNAME=${ELASTICSEARCH_USERNAME:+SET (length: ${#ELASTICSEARCH_USERNAME})}, ELASTICSEARCH_PWD=${ELASTICSEARCH_PWD:+SET (length: ${#ELASTICSEARCH_PWD})}"
+ if [[ -n "${ELASTICSEARCH_USERNAME:-}" ]] && [[ -n "${ELASTICSEARCH_PWD:-}" ]]; then
+ # Quote password to handle special characters (!, {, }, #, %, ;, etc.)
+ # Java system properties don't require quotes, but we need to ensure bash doesn't interpret special chars
+ java_cmd="${java_cmd}
+ -Des.username=\"${ELASTICSEARCH_USERNAME}\"
+ -Des.password=\"${ELASTICSEARCH_PWD}\""
+ echo_debug "Added Elasticsearch authentication arguments to reindex command"
+ else
+ echo_debug "Elasticsearch credentials not provided for reindex - ELASTICSEARCH_USERNAME=${ELASTICSEARCH_USERNAME:-}, ELASTICSEARCH_PWD=${ELASTICSEARCH_PWD:-}"
+ fi
+
+ java_cmd="${java_cmd}
+ -Dspring.flyway.enabled=${MIGRATIONS_ENABLED}
+ -jar ${OSMT_JAR}"
+
+ echo_debug "Reindex java command: ${java_cmd}"
+ run_cmd_with_retry "${java_cmd}"
+ fi
+}
+
+function start_spring_app() {
+ echo_debug "start_spring_app() called"
+ echo_debug "ENVIRONMENT at start of start_spring_app(): '${ENVIRONMENT}'"
+ echo_debug "ENVIRONMENT length: ${#ENVIRONMENT}"
+ echo_debug "SPRING_PROFILES_ACTIVE env var: '${SPRING_PROFILES_ACTIVE:-}'"
+
+ # Log all environment variables that might affect Spring profiles
+ echo_debug "All environment variables containing 'PROFILE' or 'ENVIRONMENT':"
+ env | grep -iE "(PROFILE|ENVIRONMENT)" | while IFS= read -r line; do
+ echo_debug " ${line}"
+ done || true
+
+ local java_cmd="/bin/java
+ -Dspring.profiles.active=${ENVIRONMENT}
+ -Dapp.baseDomain=${BASE_DOMAIN}
+ -Dapp.frontendUrl=${FRONTEND_URL}
+ -Dredis.uri=${REDIS_URI}
+ -Ddb.uri=${DB_URI}
+ -Des.uri=${ELASTICSEARCH_URI}"
+
+ echo_debug "Initial java command (before OAuth/auth checks): ${java_cmd}"
+
+ # Add Elasticsearch credentials if provided (for fine-grained access control)
+ echo_debug "Checking Elasticsearch credentials: ELASTICSEARCH_USERNAME=${ELASTICSEARCH_USERNAME:+SET (length: ${#ELASTICSEARCH_USERNAME})}, ELASTICSEARCH_PWD=${ELASTICSEARCH_PWD:+SET (length: ${#ELASTICSEARCH_PWD})}"
+ if [[ -n "${ELASTICSEARCH_USERNAME:-}" ]] && [[ -n "${ELASTICSEARCH_PWD:-}" ]]; then
+ # Quote password to handle special characters (!, {, }, #, %, ;, etc.)
+ # Java system properties don't require quotes, but we need to ensure bash doesn't interpret special chars
+ java_cmd="${java_cmd}
+ -Des.username=\"${ELASTICSEARCH_USERNAME}\"
+ -Des.password=\"${ELASTICSEARCH_PWD}\""
+ echo_debug "Added Elasticsearch authentication arguments"
+ else
+ echo_debug "Elasticsearch credentials not provided - ELASTICSEARCH_USERNAME=${ELASTICSEARCH_USERNAME:-}, ELASTICSEARCH_PWD=${ELASTICSEARCH_PWD:-}"
+ fi
+
+ # OAuth config passed via env; Spring Boot reads spring.security.oauth2.* from env
+
+ # Add admin auth variables if using single-auth profile
+ if [[ "${ENVIRONMENT}" == *"single-auth"* ]]; then
+ echo_debug "ENVIRONMENT contains single-auth, checking for test auth vars"
+ if [[ -n "${TEST_ROLE:-}" ]]; then
+ java_cmd="${java_cmd}
+ -DTEST_ROLE=${TEST_ROLE}"
+ echo_info "Using test role: ${TEST_ROLE}"
+ fi
+ if [[ -n "${TEST_USER_NAME:-}" ]]; then
+ java_cmd="${java_cmd}
+ -DTEST_USER_NAME=${TEST_USER_NAME}"
+ fi
+ if [[ -n "${TEST_USER_EMAIL:-}" ]]; then
+ java_cmd="${java_cmd}
+ -DTEST_USER_EMAIL=${TEST_USER_EMAIL}"
+ fi
+ fi
+
+ java_cmd="${java_cmd}
+ -Dspring.flyway.enabled=${MIGRATIONS_ENABLED}
+ -jar ${OSMT_JAR}"
+
+ echo_debug "Final java command: ${java_cmd}"
+ echo_debug "ENVIRONMENT value being passed to Spring Boot: '${ENVIRONMENT}'"
+ echo_debug "System property -Dspring.profiles.active will be: '${ENVIRONMENT}'"
+
+ echo_info "Starting OSMT Spring Boot application using ${ENVIRONMENT} Spring profiles..."
+ run_cmd_with_retry "${java_cmd}"
+}
+
+function run_cmd_with_retry() {
+ local java_cmd="${1}"
+ local return_code=-1
+ set +e
+ until [ ${return_code} -eq 0 ]; do
+ echo_debug "Executing java command (attempt)"
+ ${java_cmd}
+ return_code=$?
+ echo_debug "Java command exit code: ${return_code}"
+ if [[ ${return_code} -ne 0 ]]; then
+ echo_info "Retrying in 10 seconds..."
+ fi
+ sleep 10
+ done
+ set -e
+}
+
+function error_handler() {
+ echo_err "Trapping at error_handler. Exiting"
+ echo_debug "Error occurred - dumping environment state:"
+ echo_debug " ENVIRONMENT='${ENVIRONMENT}'"
+ echo_debug " SPRING_PROFILES_ACTIVE='${SPRING_PROFILES_ACTIVE:-}'"
+}
+
+function main() {
+ echo_debug "=== docker_entrypoint.sh starting ==="
+ echo_debug "Script arguments: $*"
+ echo_debug "Current working directory: $(pwd)"
+ echo_debug "ENVIRONMENT at script start: '${ENVIRONMENT}'"
+ echo_debug "SPRING_PROFILES_ACTIVE at script start: '${SPRING_PROFILES_ACTIVE:-}'"
+
+ local base_dir=/opt/osmt
+ if [[ ! -d "${base_dir}" || ! -r "${base_dir}" ]]; then
+ echo_err "Can not change directory to ${base_dir}. Exiting..."
+ exit 135
+ fi
+
+ echo_info "Changing directory to ${base_dir}."
+ cd "${base_dir}"
+ echo_debug "Changed to directory: $(pwd)"
+
+ OSMT_JAR="${base_dir}/bin/osmt.jar"
+ echo_debug "OSMT_JAR path: ${OSMT_JAR}"
+ echo_debug "OSMT_JAR exists: $([ -f "${OSMT_JAR}" ] && echo 'yes' || echo 'no')"
+
+ validate
+ echo_debug "After validate(), ENVIRONMENT='${ENVIRONMENT}'"
+
+ import_metadata
+ echo_debug "After import_metadata(), ENVIRONMENT='${ENVIRONMENT}'"
+
+ reindex_elasticsearch
+ echo_debug "After reindex_elasticsearch(), ENVIRONMENT='${ENVIRONMENT}'"
+
+ start_spring_app
+ echo_debug "After start_spring_app(), ENVIRONMENT='${ENVIRONMENT}'"
+}
+
+trap error_handler ERR
+
+main
diff --git a/docker/base-images/etc/env.d/10-setJava11Opts.sh b/api/docker/etc/env.d/10-setJava11Opts.sh
similarity index 100%
rename from docker/base-images/etc/env.d/10-setJava11Opts.sh
rename to api/docker/etc/env.d/10-setJava11Opts.sh
diff --git a/docker/mysql-init/1init.sql b/api/docker/mysql-init/1init.sql
similarity index 100%
rename from docker/mysql-init/1init.sql
rename to api/docker/mysql-init/1init.sql
diff --git a/docker/whitelabel/whitelabel.json b/api/docker/whitelabel/whitelabel.json
similarity index 59%
rename from docker/whitelabel/whitelabel.json
rename to api/docker/whitelabel/whitelabel.json
index 4083b53e7..f88389995 100644
--- a/docker/whitelabel/whitelabel.json
+++ b/api/docker/whitelabel/whitelabel.json
@@ -5,9 +5,11 @@
"toolNameLong": "Open Skills Management Tool",
"publicSkillTitle": "Rich Skill Descriptor",
"publicCollectionTitle": "Rich Skill Descriptor Collection",
- "licensePrimary": "Copyright © Open Skills Network",
+ "licensePrimary": "Copyright © OSMT Contributors",
"licenseSecondary": "All rights reserved.",
- "poweredBy": "Powered by the",
- "poweredByUrl": "https://rsd.osmt.dev",
- "poweredByLabel": "Open Skills Network"
+ "poweredBy": "",
+ "poweredByUrl": "",
+ "poweredByLabel": "",
+ "colorBrandAccent1": "#1e40af",
+ "logoUrl": "/assets/images/logo-light.svg"
}
diff --git a/api/osmt-dev-stack.env.example b/api/osmt-dev-stack.env.example
index e9c849479..6d0f07922 100644
--- a/api/osmt-dev-stack.env.example
+++ b/api/osmt-dev-stack.env.example
@@ -2,7 +2,39 @@
# Uncomment this line when you use ng serve for front end development, or export OSMT_FRONT_END_PORT=4200 to your shell
#OSMT_FRONT_END_PORT=4200
+# OAuth2 Configuration (Optional - omit for single-auth profile)
+# If OAuth values are missing or 'xxxxxx', OSMT will use 'single-auth' profile automatically
+# For local development without OAuth2 provider, you can leave these as 'xxxxxx'
+#
+# Okta: Replace with your OAuth2/OIDC values from Okta
OAUTH_ISSUER=https://xxxxxx.okta.com/oauth2/default
OAUTH_CLIENTID=xxxxxx
OAUTH_CLIENTSECRET=xxxxxx
OAUTH_AUDIENCE=xxxxxx
+#
+# Google (alternative): Create OAuth credentials in Google Cloud Console
+#OAUTH_GOOGLE_CLIENT_ID=xxxxxx.apps.googleusercontent.com
+#OAUTH_GOOGLE_CLIENT_SECRET=xxxxxx
+
+# Staging: Enable both OAuth and single-auth on login page
+#ENABLE_SINGLE_AUTH=true
+
+# Session Token - required for oauth2 and single-auth (Bearer)
+# APP_SESSION_TOKEN_EXPIRY_SECONDS controls both the JWT token expiry AND the
+# server-side HTTP session timeout (in Redis). Default: 86400 (24 hours)
+# APP_SESSION_TOKEN_EXPIRY_SECONDS=86400
+#
+# Dev profile provides a placeholder secret. Production: use 'openssl rand -base64 32'
+# APP_SESSION_TOKEN_SECRET=base64-encoded-secret-min-256-bits
+# APP_SESSION_TOKEN_ISSUER=https://your-domain.com
+
+# Admin Auth Configuration (Optional - only used with single-auth profile)
+# These variables are used when running with the 'single-auth' profile for local development
+#SINGLE_AUTH_ADMIN_USERNAME=admin
+#SINGLE_AUTH_ADMIN_PASSWORD=admin
+
+# Credential Engine sync (optional - for CE sync and bin/test-credential-engine-sync.sh)
+#CREDENTIAL_ENGINE_API_KEY=
+#CREDENTIAL_ENGINE_ORG_CTID=
+#CREDENTIAL_ENGINE_REGISTRY_URL=https://sandbox.credentialengine.org
+# CREDENTIAL_ENGINE_CANONICAL_URL_BASE= # Dev default: http://localhost:8080 (CE accepts localhost).
diff --git a/api/osmt-staging.env.example b/api/osmt-staging.env.example
new file mode 100644
index 000000000..f6c2a0dd2
--- /dev/null
+++ b/api/osmt-staging.env.example
@@ -0,0 +1,30 @@
+# Staging: Google OAuth2 + Single-Auth
+# Both options presented on the login page
+
+# Google OAuth2 - Create credentials in Google Cloud Console
+# Redirect URI: {baseUrl}/login/oauth2/code/google
+OAUTH_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
+OAUTH_GOOGLE_CLIENT_SECRET=your-client-secret
+
+# Enable single-auth alongside OAuth (admin fallback on login page)
+ENABLE_SINGLE_AUTH=true
+
+# Admin credentials for single-auth option
+SINGLE_AUTH_ADMIN_USERNAME=admin
+SINGLE_AUTH_ADMIN_PASSWORD=secure-password
+
+# Session Token
+# APP_SESSION_TOKEN_EXPIRY_SECONDS controls both the JWT token expiry AND the
+# server-side HTTP session timeout (in Redis). Default: 86400 (24 hours)
+# APP_SESSION_TOKEN_EXPIRY_SECONDS=86400
+#
+# Session token secret - required for staging (or use dev profile for local)
+# APP_SESSION_TOKEN_SECRET=base64-encoded-secret-min-256-bits
+
+# Profiles: oauth2,single-auth (set via ENVIRONMENT or docker entrypoint)
+
+# Credential Engine sync (optional - when absent, mock in dev / disabled in prod)
+CREDENTIAL_ENGINE_API_KEY=
+CREDENTIAL_ENGINE_ORG_CTID=
+CREDENTIAL_ENGINE_REGISTRY_URL=https://sandbox.credentialengine.org
+# CREDENTIAL_ENGINE_CANONICAL_URL_BASE= # Optional. When empty, uses app.baseUrl. Must be publicly resolvable.
diff --git a/api/pom.xml b/api/pom.xml
index 29c26ada4..d4b25f1c1 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -1,13 +1,26 @@
-
+
4.0.0
edu.wgu.osmt
osmt-parent
- 3.1.0-SNAPSHOT
+ 0.0.0-SNAPSHOT
+
+ central
+ Maven Central
+ https://repo1.maven.org/maven2
+
+ true
+
+
+ false
+
+
spring-repo
Spring Repository
@@ -18,35 +31,31 @@
Spring Repository Milestone
https://repo.spring.io/milestone
-
- jcenter
- jcenter
- https://jcenter.bintray.com
-
edu.wgu.osmt
osmt-api
- 3.1.0-SNAPSHOT
+ 0.0.0-SNAPSHOT
WGU Open Skills Management Toolset
- 1.7.21
+ 2.2.21
official
- 1.5.1
- 0.24.1
+ 1.9.5
+
+ 0.30.2
8.5.13
8.5.13
- 3.0.6
-
+
4.1.3
edu.wgu.osmt.ApplicationKt
- 1.19.5
- 5.1.3
+ 1.21.3
+ 5.1.3
+
3.9.2
- 2.17.1
+ 2.23.1
4.10.0
- 2.14.1
+ 2.17.1
@@ -116,7 +125,7 @@
-
+
org.elasticsearch.client
elasticsearch-rest-high-level-client
@@ -148,9 +157,8 @@
${exposed.version}
- com.okta.spring
- okta-spring-boot-starter
- ${okta.version}
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
org.jetbrains.exposed
@@ -196,7 +204,7 @@
org.jetbrains.kotlin
- kotlin-stdlib-jdk8
+ kotlin-stdlib
org.jetbrains.kotlinx
@@ -209,11 +217,6 @@
-
- mysql
- mysql-connector-java
- runtime
-
org.springframework.boot
spring-boot-starter-test
@@ -357,7 +360,17 @@
${project.basedir}/src/main/kotlin
- ${project.basedir}/src/test/kotlin
+ ${project.basedir}/src/test/kotlin
+
+
+
+ src/main/resources
+
+
+ ${project.basedir}/docker/whitelabel
+ docker/whitelabel
+
+
org.springframework.boot
@@ -372,8 +385,10 @@
kotlin-maven-plugin
${kotlin.version}
+ 21
-Xjsr305=strict
+ -Xannotation-default-target=param-property
spring
@@ -407,8 +422,11 @@
lib
**/edu/wgu/osmt/Application
- **/edu/wgu/osmt/ImportCommandRunner
- **/edu/wgu/osmt/security/SecurityConfig**
+ **/edu/wgu/osmt/ImportCommandRunner
+
+
+ **/edu/wgu/osmt/security/SecurityConfig**
+
**/ui/**
**/config/*.properties
@@ -422,17 +440,46 @@
${flywaydb.version}
jdbc:mysql://localhost:3306/osmt_db
- osmt
+ osmt_db_user
password
classpath:db/migration
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ generate-version-json
+ generate-resources
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+ {"version":"${version.abbrev}","buildTimestamp":"${version.buildtime}","extra":{}}
+
+
+
+
+
pl.project13.maven
git-commit-id-plugin
- 4.0.0
+
+ 4.9.10
get-the-git-infos
@@ -444,8 +491,11 @@
${project.basedir}/../.git
+ false
true
- ${project.build.outputDirectory}/git.properties
+
+ ${project.build.outputDirectory}/git.properties
+
^git.build.(time|version)$
^git.commit.id.(abbrev|full)$
@@ -468,6 +518,34 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 21
+
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 2.43.0
+
+
+
+ 1.8.0
+
+
+
+
+
+
+ apply
+
+ validate
+
+
+
diff --git a/api/src/main/kotlin/edu/wgu/osmt/ApiServer.kt b/api/src/main/kotlin/edu/wgu/osmt/ApiServer.kt
index 3900c2d4e..a0f2ed533 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/ApiServer.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/ApiServer.kt
@@ -5,6 +5,7 @@ import edu.wgu.osmt.auditlog.AuditLogUtils
import edu.wgu.osmt.collection.CollectionSkills
import edu.wgu.osmt.collection.CollectionTable
import edu.wgu.osmt.config.AppConfig
+import edu.wgu.osmt.credentialengine.SyncStateTable
import edu.wgu.osmt.db.addMissingColumnsStatementsPublic
import edu.wgu.osmt.jobcode.JobCodeTable
import edu.wgu.osmt.keyword.KeywordTable
@@ -32,16 +33,18 @@ import org.springframework.web.servlet.config.annotation.EnableWebMvc
class ApiServer {
val logger: Logger = LoggerFactory.getLogger(ApiServer::class.java)
- private val tableList: List = listOf(
- AuditLogTable,
- RichSkillDescriptorTable,
- JobCodeTable,
- RichSkillJobCodes,
- KeywordTable,
- RichSkillKeywords,
- CollectionTable,
- CollectionSkills
- )
+ private val tableList: List =
+ listOf(
+ AuditLogTable,
+ RichSkillDescriptorTable,
+ JobCodeTable,
+ RichSkillJobCodes,
+ KeywordTable,
+ RichSkillKeywords,
+ CollectionTable,
+ CollectionSkills,
+ SyncStateTable,
+ )
@Autowired
private lateinit var appConfig: AppConfig
@@ -50,12 +53,11 @@ class ApiServer {
private lateinit var auditLogUtils: AuditLogUtils
@Bean
- fun commandLineRunner(): CommandLineRunner {
- return CommandLineRunner {
+ fun commandLineRunner(): CommandLineRunner =
+ CommandLineRunner {
printMissingTableAndColumnStatements()
auditLogUtils.baseLineIfEmpty()
}
- }
fun printMissingTableAndColumnStatements() {
runBlocking {
@@ -64,7 +66,10 @@ class ApiServer {
tableList.forEach { table ->
transaction {
val statements = SchemaUtils.createStatements(table)
- val missingColumnStatements = SchemaUtils.addMissingColumnsStatementsPublic(table)
+ val missingColumnStatements =
+ SchemaUtils.addMissingColumnsStatementsPublic(
+ table,
+ )
if (statements.isNotEmpty()) {
missingStatements.addAll(statements)
}
@@ -77,7 +82,9 @@ class ApiServer {
logger.warn("Database out of sync with application!")
missingStatements.forEach { println("$it;") }
} else {
- logger.info("Tables ${tableList.map { it.tableName }} are in sync with application!")
+ logger.info(
+ "Tables ${tableList.map { it.tableName }} are in sync with application!",
+ )
}
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/HasAllPaginated.kt b/api/src/main/kotlin/edu/wgu/osmt/HasAllPaginated.kt
index 81eafc3f5..b1558e53e 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/HasAllPaginated.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/HasAllPaginated.kt
@@ -36,23 +36,42 @@ interface HasAllPaginated {
@RequestParam(required = false, defaultValue = "0") from: Int,
@RequestParam(
required = false,
- defaultValue = PublishStatus.DEFAULT_API_PUBLISH_STATUS_SET
+ defaultValue = PublishStatus.DEFAULT_API_PUBLISH_STATUS_SET,
) status: Array,
@RequestParam(required = false) sort: String?,
- @AuthenticationPrincipal user: Jwt?
+ @AuthenticationPrincipal user: Jwt?,
): HttpEntity> {
-
- val publishStatuses = status.mapNotNull {
- val status = PublishStatus.forApiValue(it)
- if (user == null && (status == PublishStatus.Deleted || status == PublishStatus.Draft)) null else status
- }.toSet()
+ val publishStatuses =
+ status
+ .mapNotNull {
+ val publishStatus = PublishStatus.forApiValue(it)
+ if (user == null &&
+ (
+ publishStatus == PublishStatus.Deleted ||
+ publishStatus == PublishStatus.Draft
+ )
+ ) {
+ null
+ } else {
+ publishStatus
+ }
+ }.toSet()
val sortEnum: SortOrder = sortOrderCompanion.forValueOrDefault(sort)
val pageable = OffsetPageable(from, size, sortEnum.sort)
- val searchHits: SearchHits = elasticRepository.findAllFilteredByPublishStatus(publishStatuses, pageable)
+ val searchHits: SearchHits =
+ elasticRepository.findAllFilteredByPublishStatus(
+ publishStatuses,
+ pageable,
+ )
val responseHeaders = HttpHeaders()
- val countAllFilteredByPublishStatus: Long = elasticRepository.countAllFilteredByPublishStatus(publishStatuses, pageable)
+ val countAllFilteredByPublishStatus: Long =
+ elasticRepository
+ .countAllFilteredByPublishStatus(
+ publishStatuses,
+ pageable,
+ )
responseHeaders.add("X-Total-Count", countAllFilteredByPublishStatus.toString())
// build up current uri with path and params
@@ -61,15 +80,17 @@ interface HasAllPaginated {
.queryParam(RoutePaths.QueryParams.FROM, from)
.queryParam(RoutePaths.QueryParams.SIZE, size)
.queryParam(RoutePaths.QueryParams.SORT, sort)
- .queryParam(RoutePaths.QueryParams.STATUS, status.joinToString(",").toLowerCase())
+ .queryParam(RoutePaths.QueryParams.STATUS, status.joinToString(",").lowercase())
PaginatedLinks(
pageable,
- searchHits.totalHits.toInt(),
- uriComponentsBuilder
+ countAllFilteredByPublishStatus.toInt(),
+ uriComponentsBuilder,
).addToHeaders(responseHeaders)
- return ResponseEntity.status(200).headers(responseHeaders)
+ return ResponseEntity
+ .status(200)
+ .headers(responseHeaders)
.body(searchHits.map { it.content }.toList())
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/ImportCommandRunner.kt b/api/src/main/kotlin/edu/wgu/osmt/ImportCommandRunner.kt
index 6fc66449b..675cec0ee 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/ImportCommandRunner.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/ImportCommandRunner.kt
@@ -42,17 +42,27 @@ class ImportCommandRunner : CommandLineRunner {
* --import-type=
* must match an entry in [[ImportType]]
*/
- val importType = arguments.find { it.contains("--import-type") }?.split("=")?.last()
- ?.let { ImportType.valueOf(it.toLowerCase().capitalize()) } ?: ImportType.Batchskill
+ val importType =
+ arguments
+ .find { it.contains("--import-type") }
+ ?.split("=")
+ ?.last()
+ ?.let {
+ ImportType.valueOf(
+ it.lowercase().replaceFirstChar { char ->
+ char.uppercaseChar()
+ },
+ )
+ }
+ ?: ImportType.Batchskill
if (csvPath != null) {
- LOG.info("running import command for ${importType}")
+ LOG.info("running import command for $importType")
when (importType) {
ImportType.Batchskill -> batchImportRichSkill.processCsv(csvPath)
ImportType.Bls -> blsImport.processCsv(csvPath)
ImportType.Onet -> onetImport.processCsv(csvPath)
}
-
} else {
LOG.error("Missing --csv=path/to/csv argument")
}
@@ -64,5 +74,5 @@ class ImportCommandRunner : CommandLineRunner {
enum class ImportType {
Batchskill,
Onet,
- Bls
+ Bls,
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/NullIfEmptyExtensions.kt b/api/src/main/kotlin/edu/wgu/osmt/NullIfEmptyExtensions.kt
index 086ca9b60..06e1e0c78 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/NullIfEmptyExtensions.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/NullIfEmptyExtensions.kt
@@ -1,11 +1,10 @@
package edu.wgu.osmt
-fun String?.nullIfEmpty(): String? {
- return if (this == ""){
+fun String?.nullIfEmpty(): String? =
+ if (this == "") {
null
- } else this
-}
+ } else {
+ this
+ }
-fun List.nullIfEmpty(): List? {
- return if (this.isEmpty()) null else this
-}
+fun List.nullIfEmpty(): List? = if (this.isEmpty()) null else this
diff --git a/api/src/main/kotlin/edu/wgu/osmt/PropertyLogger.kt b/api/src/main/kotlin/edu/wgu/osmt/PropertyLogger.kt
index 5b0299c72..b1f168de2 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/PropertyLogger.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/PropertyLogger.kt
@@ -13,7 +13,6 @@ import java.util.stream.Collectors
import java.util.stream.Stream
import java.util.stream.StreamSupport
-
@Component
@Profile("debug")
class PropertyLogger {
@@ -23,18 +22,24 @@ class PropertyLogger {
LOGGER.info("====== Environment and configuration ======")
LOGGER.info("Active profiles: {}", Arrays.toString(env.activeProfiles))
val sources = (env as AbstractEnvironment).propertySources
- val unsecretProperties: Stream = StreamSupport.stream(sources.spliterator(), false)
- .filter { ps: PropertySource<*>? -> ps is EnumerablePropertySource<*> }
- .map { ps: PropertySource<*> -> (ps as EnumerablePropertySource<*>).propertyNames }
- .flatMap { array: Array? -> Arrays.stream(array) }
- .distinct()
- .filter { prop: String -> !(
- prop.contains("credentials", true) ||
+ val unsecretProperties: Stream =
+ StreamSupport
+ .stream(sources.spliterator(), false)
+ .filter { ps: PropertySource<*>? -> ps is EnumerablePropertySource<*> }
+ .map { ps: PropertySource<*> -> (ps as EnumerablePropertySource<*>).propertyNames }
+ .flatMap { array: Array? -> Arrays.stream(array) }
+ .distinct()
+ .filter { prop: String ->
+ !(
+ prop.contains("credentials", true) ||
prop.contains("secret", true) ||
prop.contains("password", true)
- )}
+ )
+ }
- unsecretProperties.sorted().collect(Collectors.toList())
+ unsecretProperties
+ .sorted()
+ .collect(Collectors.toList())
.forEach { prop: String? -> LOGGER.info("{}: {}", prop, env.getProperty(prop!!)) }
LOGGER.info("===========================================")
diff --git a/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt b/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt
index f3c72a6f2..2cf0facf2 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt
@@ -3,7 +3,6 @@ package edu.wgu.osmt
import org.apache.commons.lang3.StringUtils
object RoutePaths {
-
const val API_V2 = "/v2"
const val API_V3 = "/v3"
const val DEFAULT = API_V3
@@ -12,7 +11,7 @@ object RoutePaths {
const val API = "/api"
private const val SEARCH_PATH = "/search"
- //export
+ // export
private const val EXPORT = "/export"
const val SEARCH_SKILLS = "$SEARCH_PATH/skills"
const val EXPORT_LIBRARY = "$EXPORT/library"
@@ -26,7 +25,7 @@ object RoutePaths {
const val SEARCH_SIMILARITIES_RESULTS = "${SEARCH_SIMILARITIES}/results"
const val SEARCH_COLLECTIONS = "$SEARCH_PATH/collections"
- //skills
+ // skills
private const val SKILLS_PATH = "/skills"
const val SKILLS_LIST = SKILLS_PATH
const val SKILLS_CREATE = SKILLS_PATH
@@ -36,13 +35,13 @@ object RoutePaths {
const val SKILL_UPDATE = "$SKILL_DETAIL/update"
const val SKILL_AUDIT_LOG = "$SKILL_DETAIL/log"
- //categories
+ // categories
private const val CATEGORY_PATH = "/categories"
const val CATEGORY_LIST = CATEGORY_PATH
const val CATEGORY_DETAIL = "$CATEGORY_PATH/{identifier}"
const val CATEGORY_SKILLS = "$CATEGORY_DETAIL/skills"
- //collections
+ // collections
private const val COLLECTIONS_PATH = "/collections"
const val COLLECTIONS_LIST = COLLECTIONS_PATH
const val COLLECTION_CREATE = COLLECTIONS_PATH
@@ -55,6 +54,7 @@ object RoutePaths {
const val COLLECTION_CSV = "$COLLECTION_DETAIL/csv"
const val COLLECTION_XLSX = "$COLLECTION_DETAIL/xlsx"
const val COLLECTION_REMOVE = "$COLLECTION_DETAIL/remove"
+ const val COLLECTION_DUPLICATE = "$COLLECTION_DETAIL/duplicate"
const val WORKSPACE_PATH = "/workspace"
const val WORKSPACE_LIST = WORKSPACE_PATH
@@ -72,6 +72,16 @@ object RoutePaths {
const val ES_ADMIN_DELETE_INDICES = "$ES_ADMIN/delete-indices"
const val ES_ADMIN_REINDEX = "$ES_ADMIN/reindex"
+ private const val SYNC_PATH = "/sync"
+ const val SYNC_STATE = "$SYNC_PATH/state"
+ const val SYNC_SKILL = "$SYNC_PATH/skill"
+ const val SYNC_SKILL_UUID = "$SYNC_SKILL/{uuid}"
+ const val SYNC_COLLECTION = "$SYNC_PATH/collection"
+ const val SYNC_COLLECTION_UUID = "$SYNC_COLLECTION/{uuid}"
+ const val SYNC_ALL = "$SYNC_PATH/all"
+ const val SYNC_RESYNC = "$SYNC_PATH/resync"
+ const val SYNC_UNPUBLISH_ALL = "$SYNC_PATH/unpublish-all"
+
object QueryParams {
const val FROM = "from"
const val SIZE = "size"
diff --git a/api/src/main/kotlin/edu/wgu/osmt/VersionController.kt b/api/src/main/kotlin/edu/wgu/osmt/VersionController.kt
new file mode 100644
index 000000000..183cb7421
--- /dev/null
+++ b/api/src/main/kotlin/edu/wgu/osmt/VersionController.kt
@@ -0,0 +1,28 @@
+package edu.wgu.osmt
+
+import org.springframework.core.io.ClassPathResource
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+import java.nio.charset.StandardCharsets
+
+/**
+ * Serves build and version information for deployment verification.
+ *
+ * Returns the contents of [version.json] verbatim. The file is generated at build time
+ * by Maven (standalone) or by the Docker build (monorepo). See docs/plans/2026-02-25-version-endpoint.
+ */
+@RestController
+class VersionController {
+ @GetMapping("/version", produces = [MediaType.APPLICATION_JSON_VALUE])
+ fun version(): ResponseEntity {
+ val resource = ClassPathResource("version.json")
+ return if (resource.exists()) {
+ val json = resource.inputStream.use { it.readBytes().toString(StandardCharsets.UTF_8) }
+ ResponseEntity.ok().body(json)
+ } else {
+ ResponseEntity.notFound().build()
+ }
+ }
+}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/ApiErrorHandler.kt b/api/src/main/kotlin/edu/wgu/osmt/api/ApiErrorHandler.kt
index 3a35b41d2..166bceb5f 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/ApiErrorHandler.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/ApiErrorHandler.kt
@@ -14,10 +14,15 @@ import org.springframework.web.context.request.WebRequest
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
+class FormValidationException(
+ override val message: String,
+ val errors: List,
+) : Exception(message)
-class FormValidationException(override val message: String, val errors:List): Exception(message)
-
-class GeneralApiException(override val message: String, val status: HttpStatus): Exception(message)
+class GeneralApiException(
+ override val message: String,
+ val status: HttpStatus,
+) : Exception(message)
@Order(value = 0)
@ControllerAdvice
@@ -32,20 +37,26 @@ class GeneralApiExceptionHandler : ResponseEntityExceptionHandler() {
@Order(value = 1)
@ControllerAdvice
class ApiErrorHandler : ResponseEntityExceptionHandler() {
-
fun handleHttpMessageNotReadable(
ex: HttpMessageNotReadableException,
headers: HttpHeaders,
status: HttpStatus,
- request: WebRequest
+ request: WebRequest,
): ResponseEntity {
- val apiError = when (ex.rootCause) {
- is MismatchedInputException -> {
- val mie = ex.rootCause as MismatchedInputException
- ApiError("JSON Parse Error", listOf(ApiFieldError(field="body", message=mie.message!!)))
+ val apiError =
+ when (ex.rootCause) {
+ is MismatchedInputException -> {
+ val mie = ex.rootCause as MismatchedInputException
+ ApiError(
+ "JSON Parse Error",
+ listOf(ApiFieldError(field = "body", message = mie.message!!)),
+ )
+ }
+
+ else -> {
+ ApiError("Bad Request")
+ }
}
- else -> ApiError("Bad Request")
- }
return ResponseEntity(apiError, status)
}
@@ -60,5 +71,4 @@ class ApiErrorHandler : ResponseEntityExceptionHandler() {
val apiError = ApiError(ex.message)
return ResponseEntity(apiError, ex.statusCode)
}
-
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiBatchResult.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiBatchResult.kt
index 5072d09a6..15feb17eb 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiBatchResult.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiBatchResult.kt
@@ -7,13 +7,10 @@ import com.fasterxml.jackson.annotation.JsonProperty
data class ApiBatchResult(
@JsonProperty("success")
val success: Boolean = false,
-
@JsonProperty("message")
val message: String? = null,
-
@JsonProperty("modifiedCount")
val modifiedCount: Number? = null,
-
@JsonProperty("totalCount")
- val totalCount: Number? = null
+ val totalCount: Number? = null,
)
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollection.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollection.kt
index 75432130e..b0a710f9f 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollection.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollection.kt
@@ -13,13 +13,12 @@ import edu.wgu.osmt.richskill.RichSkillDescriptor
import java.time.ZoneId
import java.time.ZonedDateTime
-
@JsonInclude(JsonInclude.Include.ALWAYS)
open class ApiCollection(
@JsonIgnore open val collection: Collection,
@JsonIgnore open val ss: List,
@JsonIgnore open val keywords: Map>,
- private val appConfig: AppConfig
+ private val appConfig: AppConfig,
) {
@get:JsonProperty
val id: String
@@ -83,16 +82,23 @@ open class ApiCollection(
val owner: String?
get() = collection.workspaceOwner
+ @JsonProperty
+ @get:JsonInclude(JsonInclude.Include.NON_NULL)
+ var credentialEngineUrl: String? = null
+
companion object {
- fun fromDao(collectionDao: CollectionDao, appConfig: AppConfig): ApiCollection {
- val skills = collectionDao.skills.map{ it.toModel() }
+ fun fromDao(
+ collectionDao: CollectionDao,
+ appConfig: AppConfig,
+ ): ApiCollection {
+ val skills = collectionDao.skills.map { it.toModel() }
return ApiCollection(
collectionDao.toModel(),
skills,
RichSkillDescriptor.getKeywordsFromSkills(skills),
- appConfig
+ appConfig,
)
}
}
-}
\ No newline at end of file
+}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionUpdate.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionUpdate.kt
index e3ba5e0e3..8214901ba 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionUpdate.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionUpdate.kt
@@ -7,31 +7,28 @@ import edu.wgu.osmt.db.PublishStatus
data class ApiCollectionUpdate(
@JsonProperty("name")
val name: String? = null,
-
@JsonProperty("description")
val description: String? = null,
-
- @JsonFormat(with= [JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES])
+ @JsonFormat(with = [JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES])
@JsonProperty("status")
val publishStatus: PublishStatus? = null,
-
@JsonProperty("author")
val author: String? = null,
-
@JsonProperty("skills")
- val skills: ApiStringListUpdate? = null
+ val skills: ApiStringListUpdate? = null,
) {
-
- fun validate(rowNumber:Number? = null): List? {
+ fun validate(_rowNumber: Number? = null): List? {
val errors = mutableListOf()
return if (errors.size > 0) errors else null
}
- fun validateForCreation(rowNumber:Number? = null): List? {
+ fun validateForCreation(rowNumber: Number? = null): List? {
val errors = mutableListOf()
if (name.isNullOrBlank()) {
- errors.add(ApiFieldError(field = "name", message = "Name is required", rowNumber = rowNumber))
+ errors.add(
+ ApiFieldError(field = "name", message = "Name is required", rowNumber = rowNumber),
+ )
}
validate()?.let { errors.addAll(it) }
return if (errors.size > 0) errors else null
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionV2.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionV2.kt
index b827dc813..1ef40f259 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionV2.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionV2.kt
@@ -8,17 +8,14 @@ import edu.wgu.osmt.config.AppConfig
import edu.wgu.osmt.keyword.KeywordCount
import edu.wgu.osmt.keyword.KeywordTypeEnum
import edu.wgu.osmt.richskill.RichSkillDescriptor
-import java.util.*
-
@JsonInclude(JsonInclude.Include.ALWAYS)
class ApiCollectionV2(
- collection: Collection,
- @JsonIgnore override val ss: List,
- @JsonIgnore override val keywords: Map>,
- @JsonIgnore private val appConfig: AppConfig
+ collection: Collection,
+ @JsonIgnore override val ss: List,
+ @JsonIgnore override val keywords: Map>,
+ @JsonIgnore private val appConfig: AppConfig,
) : ApiCollection(collection, ss, keywords, appConfig) {
-
@get:JsonIgnore
override val skillKeywords: Map>
get() = keywords
@@ -32,17 +29,19 @@ class ApiCollectionV2(
get() = ss.map { ApiSkillSummaryV2.fromSkill(it, appConfig) }
companion object {
-
- fun fromLatest(apiCollection: ApiCollection, appConfig: AppConfig) : ApiCollectionV2 {
-
- val result = ApiCollectionV2(
+ fun fromLatest(
+ apiCollection: ApiCollection,
+ appConfig: AppConfig,
+ ): ApiCollectionV2 {
+ val result =
+ ApiCollectionV2(
collection = apiCollection.collection,
ss = apiCollection.ss,
keywords = apiCollection.keywords,
- appConfig
- )
-
+ appConfig,
+ )
+ result.credentialEngineUrl = apiCollection.credentialEngineUrl
return result
}
}
-}
\ No newline at end of file
+}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiError.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiError.kt
index 39121303c..259acb846 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiError.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiError.kt
@@ -2,16 +2,15 @@ package edu.wgu.osmt.api.model
import com.fasterxml.jackson.annotation.JsonInclude
-
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class ApiError(
val message: String? = null,
- val errors: List = listOf()
+ val errors: List = listOf(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class ApiFieldError(
val field: String,
val message: String,
- val rowNumber: Number? = null
+ val rowNumber: Number? = null,
)
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiKeyword.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiKeyword.kt
index 7b6c1f738..28c79855a 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiKeyword.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiKeyword.kt
@@ -28,13 +28,10 @@ class ApiKeyword(
get() = totalSkills
companion object {
- fun fromDao(
- keywordDao: KeywordDao,
- ): ApiKeyword {
- return ApiKeyword(
+ fun fromDao(keywordDao: KeywordDao): ApiKeyword =
+ ApiKeyword(
keyword = keywordDao.toModel(),
totalSkills = keywordDao.skills.count(),
)
- }
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiNamedReference.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiNamedReference.kt
index 0727d03db..20b6a662b 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiNamedReference.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiNamedReference.kt
@@ -7,41 +7,45 @@ import edu.wgu.osmt.db.JobCodeLevel
import edu.wgu.osmt.jobcode.JobCode
import edu.wgu.osmt.keyword.Keyword
-
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class ApiNamedReference(
val id: String? = null,
- val name: String? = null
+ val name: String? = null,
) {
- companion object factory {
- fun fromKeyword(keyword: Keyword): ApiNamedReference {
- return ApiNamedReference(id=keyword.uri, name=keyword.value)
- }
+ companion object Factory {
+ fun fromKeyword(keyword: Keyword): ApiNamedReference = ApiNamedReference(id = keyword.uri, name = keyword.value)
}
}
-
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class ApiAlignment(
- @get:JsonProperty("id") // these explicit decorators are needed to help jackson
- @JsonProperty("id")
- val id: String? = null,
-
- @get:JsonProperty("skillName")
- @JsonProperty("skillName")
- val skillName: String? = null,
-
- @get:JsonProperty("isPartOf")
- @JsonProperty("isPartOf")
- val isPartOf: ApiNamedReference? = null
+ @get:JsonProperty("id") // these explicit decorators are needed to help jackson
+ @JsonProperty("id")
+ val id: String? = null,
+ @get:JsonProperty("skillName")
+ @JsonProperty("skillName")
+ val skillName: String? = null,
+ @get:JsonProperty("isPartOf")
+ @JsonProperty("isPartOf")
+ val isPartOf: ApiNamedReference? = null,
) {
- companion object factory {
- fun fromKeyword(keyword: Keyword): ApiAlignment {
- return fromStrings(keyword.uri, keyword.value, keyword.framework)
- }
- fun fromStrings(id: String?, skillName: String?, frameworkName: String?): ApiAlignment {
- val partOf = if (frameworkName?.isNotBlank() == true) ApiNamedReference(name=frameworkName) else null
- return ApiAlignment(id=id, skillName=skillName, isPartOf=partOf)
+ companion object Factory {
+ fun fromKeyword(keyword: Keyword): ApiAlignment = fromStrings(keyword.uri, keyword.value, keyword.framework)
+
+ fun fromStrings(
+ id: String?,
+ skillName: String?,
+ frameworkName: String?,
+ ): ApiAlignment {
+ val partOf =
+ if (frameworkName?.isNotBlank() ==
+ true
+ ) {
+ ApiNamedReference(name = frameworkName)
+ } else {
+ null
+ }
+ return ApiAlignment(id = id, skillName = skillName, isPartOf = partOf)
}
}
}
@@ -49,28 +53,27 @@ data class ApiAlignment(
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class ApiUuidReference(
val uuid: String,
- val name: String
+ val name: String,
) {
- companion object factory {
- fun fromCollection(collection: Collection): ApiUuidReference {
- return ApiUuidReference(uuid=collection.uuid, name=collection.name)
- }
+ companion object Factory {
+ fun fromCollection(collection: Collection): ApiUuidReference =
+ ApiUuidReference(uuid = collection.uuid, name = collection.name)
}
}
data class ApiReferenceListUpdate(
val add: List? = null,
- val remove: List? = null
+ val remove: List? = null,
)
data class ApiAlignmentListUpdate(
val add: List? = null,
- val remove: List? = null
+ val remove: List? = null,
)
data class ApiStringListUpdate(
val add: List? = null,
- val remove: List? = null
+ val remove: List? = null,
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@@ -80,11 +83,21 @@ data class ApiJobCode(
val targetNodeName: String? = null,
val frameworkName: String? = null,
val level: JobCodeLevel? = null,
- val parents: List? = null
+ val parents: List? = null,
) {
- companion object factory {
- fun fromJobCode(jobCode: JobCode, level: JobCodeLevel? = null, parents: List? = null): ApiJobCode {
- return ApiJobCode(code=jobCode.code, targetNodeName=jobCode.name, targetNode=jobCode.url, frameworkName=jobCode.framework, level=level, parents=parents)
- }
+ companion object Factory {
+ fun fromJobCode(
+ jobCode: JobCode,
+ level: JobCodeLevel? = null,
+ parents: List? = null,
+ ): ApiJobCode =
+ ApiJobCode(
+ code = jobCode.code,
+ targetNodeName = jobCode.name,
+ targetNode = jobCode.url,
+ frameworkName = jobCode.framework,
+ level = level,
+ parents = parents,
+ )
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearch.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearch.kt
index c29536470..06cb7667b 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearch.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearch.kt
@@ -7,88 +7,65 @@ import com.fasterxml.jackson.annotation.JsonProperty
data class ApiSearch(
@JsonProperty("query")
val query: String? = null,
-
@JsonProperty("advanced")
val advanced: ApiAdvancedSearch? = null,
-
@JsonProperty("filtered")
val filtered: ApiFilteredSearch? = null,
-
@JsonProperty("uuids")
- val uuids: List? = null
+ val uuids: List? = null,
)
@JsonInclude(JsonInclude.Include.ALWAYS)
data class ApiAdvancedSearch(
@JsonProperty("skillName")
val skillName: String? = null,
-
@JsonProperty("collectionName")
val collectionName: String? = null,
-
@JsonProperty("category")
val category: String? = null,
-
@JsonProperty("skillStatement")
val skillStatement: String? = null,
-
@JsonProperty("author")
val author: String? = null,
-
@JsonProperty("keywords")
val keywords: List? = null,
-
@JsonProperty("occupations")
val occupations: List? = null,
-
@JsonProperty("standards")
val standards: List? = null,
-
@JsonProperty("certifications")
val certifications: List? = null,
-
@JsonProperty("employers")
val employers: List? = null,
-
@JsonProperty("alignments")
- val alignments: List? = null
+ val alignments: List? = null,
)
data class ApiSkillListUpdate(
@JsonProperty("add")
val add: ApiSearch? = null,
-
@JsonProperty("remove")
- val remove: ApiSearch? = null
+ val remove: ApiSearch? = null,
)
@JsonInclude(JsonInclude.Include.ALWAYS)
data class ApiFilteredSearch(
-
@JsonProperty("categories")
val categories: List? = null,
-
@JsonProperty("keywords")
val keywords: List? = null,
-
@JsonProperty("standards")
val standards: List? = null,
-
@JsonProperty("certifications")
val certifications: List? = null,
-
@JsonProperty("alignments")
val alignments: List? = null,
-
@JsonProperty("jobcodes")
val jobCodes: List? = null,
-
@JsonProperty("employers")
val employers: List? = null,
-
@JsonProperty("authors")
val authors: List? = null,
-
@JsonProperty("occupations")
- val occupations: List? = null
-)
\ No newline at end of file
+ val occupations: List? = null,
+)
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearchV2.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearchV2.kt
index 1c97d12f7..5b19a552e 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearchV2.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearchV2.kt
@@ -8,12 +8,10 @@ import com.fasterxml.jackson.annotation.JsonRootName
data class ApiSearchV2(
@JsonProperty("query")
val query: String? = null,
-
@JsonProperty("advanced")
val advanced: ApiAdvancedSearch? = null,
-
@JsonProperty("uuids")
- val uuids: List? = null
+ val uuids: List? = null,
)
@JsonInclude(JsonInclude.Include.ALWAYS)
@@ -21,43 +19,32 @@ data class ApiSearchV2(
data class ApiAdvancedSearchV2(
@JsonProperty("skillName")
val skillName: String? = null,
-
@JsonProperty("collectionName")
val collectionName: String? = null,
-
@JsonProperty("category")
val category: String? = null,
-
@JsonProperty("skillStatement")
val skillStatement: String? = null,
-
@JsonProperty("author")
val author: String? = null,
-
@JsonProperty("keywords")
val keywords: List? = null,
-
@JsonProperty("occupations")
val occupations: List? = null,
-
@JsonProperty("standards")
val standards: List? = null,
-
@JsonProperty("certifications")
val certifications: List? = null,
-
@JsonProperty("employers")
val employers: List? = null,
-
@JsonProperty("alignments")
- val alignments: List? = null
+ val alignments: List? = null,
)
@JsonRootName("apiSkillListUpdate")
data class ApiSkillListUpdateV2(
@JsonProperty("add")
val add: ApiSearch? = null,
-
@JsonProperty("remove")
- val remove: ApiSearch? = null
-)
\ No newline at end of file
+ val remove: ApiSearch? = null,
+)
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSimilaritySearch.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSimilaritySearch.kt
index 1184510f0..121310135 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSimilaritySearch.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSimilaritySearch.kt
@@ -6,5 +6,5 @@ import com.fasterxml.jackson.annotation.JsonProperty
@JsonInclude(JsonInclude.Include.ALWAYS)
data class ApiSimilaritySearch(
@JsonProperty("statement")
- val statement: String
+ val statement: String,
)
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkill.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkill.kt
index 5162bf18a..94c92ab54 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkill.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkill.kt
@@ -12,14 +12,15 @@ import edu.wgu.osmt.richskill.RichSkillDescriptorDao
import java.time.ZoneId
import java.time.ZonedDateTime
-
@JsonInclude(JsonInclude.Include.ALWAYS)
-open class ApiSkill(@JsonIgnore open val rsd: RichSkillDescriptor, @JsonIgnore open val cs: Set, private val appConfig: AppConfig) {
-
+open class ApiSkill(
+ @JsonIgnore open val rsd: RichSkillDescriptor,
+ @JsonIgnore open val cs: Set,
+ private val appConfig: AppConfig,
+) {
@JsonProperty("@context")
val context = appConfig.rsdContextUrl
-
@JsonProperty
val `type` = "RichSkillDescriptor"
@@ -91,14 +92,47 @@ open class ApiSkill(@JsonIgnore open val rsd: RichSkillDescriptor, @JsonIgnore o
val occupations: List
get() {
return rsd.jobCodes.filter { it.code.isNotBlank() }.map { jobCode ->
- val parents = listOfNotNull(
- jobCode.major.let {jobCode.majorCode?.let { ApiJobCode(code=it, targetNodeName=jobCode.major, level=JobCodeLevel.Major) }},
- jobCode.minor.let{jobCode.minorCode?.let { ApiJobCode(code=it, targetNodeName=jobCode.minor, level=JobCodeLevel.Minor) }},
- jobCode.broad?.let {jobCode.broadCode?.let { ApiJobCode(code=it, targetNodeName=jobCode.broad, level=JobCodeLevel.Broad) }},
- jobCode.detailed?.let {jobCode.detailedCode?.let { ApiJobCode(code=it, targetNodeName=jobCode.detailed, level=JobCodeLevel.Detailed) }}
- ).distinct()
-
- ApiJobCode.fromJobCode(jobCode, parents=parents)
+ val parents =
+ listOfNotNull(
+ jobCode.major.let {
+ jobCode.majorCode?.let {
+ ApiJobCode(
+ code = it,
+ targetNodeName = jobCode.major,
+ level = JobCodeLevel.Major,
+ )
+ }
+ },
+ jobCode.minor.let {
+ jobCode.minorCode?.let {
+ ApiJobCode(
+ code = it,
+ targetNodeName = jobCode.minor,
+ level = JobCodeLevel.Minor,
+ )
+ }
+ },
+ jobCode.broad?.let {
+ jobCode.broadCode?.let {
+ ApiJobCode(
+ code = it,
+ targetNodeName = jobCode.broad,
+ level = JobCodeLevel.Broad,
+ )
+ }
+ },
+ jobCode.detailed?.let {
+ jobCode.detailedCode?.let {
+ ApiJobCode(
+ code = it,
+ targetNodeName = jobCode.detailed,
+ level = JobCodeLevel.Detailed,
+ )
+ }
+ },
+ ).distinct()
+
+ ApiJobCode.fromJobCode(jobCode, parents = parents)
}
}
@@ -110,12 +144,22 @@ open class ApiSkill(@JsonIgnore open val rsd: RichSkillDescriptor, @JsonIgnore o
val collections: List
get() = cs.map { ApiUuidReference.fromCollection(it) }
+ @JsonProperty
+ @get:JsonInclude(JsonInclude.Include.NON_NULL)
+ var credentialEngineUrl: String? = null
+
companion object {
- fun fromDao(rsdDao: RichSkillDescriptorDao, appConfig: AppConfig): ApiSkill{
- return ApiSkill(rsdDao.toModel(), rsdDao.collections.map{ it.toModel() }.filter { !it.isWorkspace() }.toSet(), appConfig)
- }
+ fun fromDao(
+ rsdDao: RichSkillDescriptorDao,
+ appConfig: AppConfig,
+ ): ApiSkill =
+ ApiSkill(
+ rsdDao.toModel(),
+ rsdDao.collections
+ .map { it.toModel() }
+ .filter { !it.isWorkspace() }
+ .toSet(),
+ appConfig,
+ )
}
}
-
-
-
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillSummary.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillSummary.kt
index a3f7af40f..a17435f99 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillSummary.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillSummary.kt
@@ -18,12 +18,14 @@ open class ApiSkillSummary(
@JsonProperty open val skillStatement: String,
@JsonProperty open val categories: List = listOf(),
@JsonProperty open val keywords: List = listOf(),
- @JsonProperty open val occupations: List = listOf()
+ @JsonProperty open val occupations: List = listOf(),
) {
-
companion object {
- fun fromSkill(rsd: RichSkillDescriptor, appConfig: AppConfig): ApiSkillSummary {
- return ApiSkillSummary(
+ fun fromSkill(
+ rsd: RichSkillDescriptor,
+ appConfig: AppConfig,
+ ): ApiSkillSummary =
+ ApiSkillSummary(
id = rsd.canonicalUrl(appConfig.baseUrl),
uuid = rsd.uuid,
status = rsd.publishStatus(),
@@ -33,16 +35,16 @@ open class ApiSkillSummary(
skillStatement = rsd.statement,
categories = rsd.categories.mapNotNull { it.value },
keywords = rsd.keywords.mapNotNull { it.value },
- occupations = rsd.jobCodes.map { ApiJobCode.fromJobCode(it) }
+ occupations = rsd.jobCodes.map { ApiJobCode.fromJobCode(it) },
)
- }
- fun fromDao(rsdDao: RichSkillDescriptorDao, appConfig: AppConfig): ApiSkillSummary {
- return fromSkill(rsdDao.toModel(), appConfig)
- }
+ fun fromDao(
+ rsdDao: RichSkillDescriptorDao,
+ appConfig: AppConfig,
+ ): ApiSkillSummary = fromSkill(rsdDao.toModel(), appConfig)
- fun fromDoc(rsDoc: RichSkillDoc): ApiSkillSummary {
- return with(rsDoc) {
+ fun fromDoc(rsDoc: RichSkillDoc): ApiSkillSummary =
+ with(rsDoc) {
ApiSkillSummary(
uri,
uuid,
@@ -53,9 +55,8 @@ open class ApiSkillSummary(
statement,
categories,
searchingKeywords,
- jobCodes.map { ApiJobCode.fromJobCode(it) })
+ jobCodes.map { ApiJobCode.fromJobCode(it) },
+ )
}
- }
}
}
-
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillSummaryV2.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillSummaryV2.kt
index c41354d82..3f264e1ec 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillSummaryV2.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillSummaryV2.kt
@@ -19,13 +19,23 @@ class ApiSkillSummaryV2(
@JsonIgnore override val categories: List = listOf(),
@JsonProperty val category: String? = null,
@JsonProperty override val keywords: List = listOf(),
- @JsonProperty override val occupations: List = listOf()
-): ApiSkillSummary(id, uuid, status, publishDate, archiveDate, skillName, skillStatement, categories) {
-
+ @JsonProperty override val occupations: List = listOf(),
+) : ApiSkillSummary(
+ id,
+ uuid,
+ status,
+ publishDate,
+ archiveDate,
+ skillName,
+ skillStatement,
+ categories,
+ ) {
companion object {
-
- fun fromSkill(rsd: RichSkillDescriptor, appConfig: AppConfig): ApiSkillSummaryV2 {
- return ApiSkillSummaryV2(
+ fun fromSkill(
+ rsd: RichSkillDescriptor,
+ appConfig: AppConfig,
+ ): ApiSkillSummaryV2 =
+ ApiSkillSummaryV2(
id = rsd.canonicalUrl(appConfig.baseUrl),
uuid = rsd.uuid,
status = rsd.publishStatus(),
@@ -34,14 +44,17 @@ class ApiSkillSummaryV2(
skillName = rsd.name,
skillStatement = rsd.statement,
categories = rsd.categories.mapNotNull { it.value },
- category = rsd.categories.mapNotNull { it.value }.sorted().joinToString(SEMICOLON),
+ category =
+ rsd.categories
+ .mapNotNull { it.value }
+ .sorted()
+ .joinToString(SEMICOLON),
keywords = rsd.keywords.mapNotNull { it.value },
- occupations = rsd.jobCodes.map { ApiJobCode.fromJobCode(it) }
+ occupations = rsd.jobCodes.map { ApiJobCode.fromJobCode(it) },
)
- }
-
- fun fromLatest(apiSkillSummary: ApiSkillSummary): ApiSkillSummaryV2 {
- return ApiSkillSummaryV2(
+
+ fun fromLatest(apiSkillSummary: ApiSkillSummary): ApiSkillSummaryV2 =
+ ApiSkillSummaryV2(
id = apiSkillSummary.id,
uuid = apiSkillSummary.uuid,
status = apiSkillSummary.status,
@@ -52,10 +65,7 @@ class ApiSkillSummaryV2(
categories = apiSkillSummary.categories,
category = apiSkillSummary.categories.sorted().joinToString(SEMICOLON),
keywords = apiSkillSummary.keywords,
- occupations = apiSkillSummary.occupations
+ occupations = apiSkillSummary.occupations,
)
-
- }
}
}
-
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdate.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdate.kt
index 35231a3bc..d069116df 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdate.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdate.kt
@@ -6,59 +6,53 @@ import edu.wgu.osmt.db.PublishStatus
data class ApiSkillUpdate(
@JsonProperty("skillName")
override val skillName: String? = null,
-
@JsonProperty("skillStatement")
override val skillStatement: String? = null,
-
@JsonProperty("status")
override val publishStatus: PublishStatus? = null,
-
@JsonProperty("collections")
override val collections: ApiStringListUpdate? = null,
-
@JsonProperty("authors")
val authors: ApiStringListUpdate? = null,
-
@JsonProperty("categories")
val categories: ApiStringListUpdate? = null,
-
@JsonProperty("keywords")
override val keywords: ApiStringListUpdate? = null,
-
@JsonProperty("certifications")
override val certifications: ApiReferenceListUpdate? = null,
-
@JsonProperty("standards")
override val standards: ApiAlignmentListUpdate? = null,
-
@JsonProperty("alignments")
override val alignments: ApiAlignmentListUpdate? = null,
-
@JsonProperty("employers")
override val employers: ApiReferenceListUpdate? = null,
-
@JsonProperty("occupations")
- override val occupations: ApiStringListUpdate? = null
+ override val occupations: ApiStringListUpdate? = null,
) : SkillUpdate {
-
- fun validate(rowNumber: Number? = null): List? {
+ fun validate(_rowNumber: Number? = null): List? {
val errors = mutableListOf()
return if (errors.size > 0) errors else null
}
-
+
fun validateForCreation(rowNumber: Number? = null): List? {
val errors = mutableListOf()
if (skillName.isNullOrBlank()) {
- errors.add(ApiFieldError(field = "skillName", message = "Name is required", rowNumber = rowNumber))
+ errors.add(
+ ApiFieldError(
+ field = "skillName",
+ message = "Name is required",
+ rowNumber = rowNumber,
+ ),
+ )
}
if (skillStatement.isNullOrBlank()) {
errors.add(
ApiFieldError(
field = "skillStatement",
message = "Statement is required",
- rowNumber = rowNumber
- )
+ rowNumber = rowNumber,
+ ),
)
}
@@ -79,4 +73,4 @@ interface SkillUpdate {
val alignments: ApiAlignmentListUpdate?
val employers: ApiReferenceListUpdate?
val occupations: ApiStringListUpdate?
-}
\ No newline at end of file
+}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdateMapper.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdateMapper.kt
index 9ba593c55..51f7975fe 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdateMapper.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdateMapper.kt
@@ -4,39 +4,56 @@ import edu.wgu.osmt.keyword.KeywordTypeEnum
import edu.wgu.osmt.richskill.RichSkillRepository
class ApiSkillUpdateMapper {
-
companion object {
fun mapApiSkillUpdateV2ToApiSkillUpdate(
- apiSkillUpdateV2: ApiSkillUpdateV2,
- skillUUID: String,
- richSkillRepository: RichSkillRepository
- ): ApiSkillUpdate {
- return ApiSkillUpdate(
- skillName = apiSkillUpdateV2.skillName,
- skillStatement = apiSkillUpdateV2.skillStatement,
- publishStatus = apiSkillUpdateV2.publishStatus,
- collections = apiSkillUpdateV2.collections,
- authors = getApiStringListUpdate(apiSkillUpdateV2.author, skillUUID, richSkillRepository, KeywordTypeEnum.Author),
- categories = getApiStringListUpdate(apiSkillUpdateV2.category, skillUUID, richSkillRepository, KeywordTypeEnum.Category)
+ apiSkillUpdateV2: ApiSkillUpdateV2,
+ skillUUID: String,
+ richSkillRepository: RichSkillRepository,
+ ): ApiSkillUpdate =
+ ApiSkillUpdate(
+ skillName = apiSkillUpdateV2.skillName,
+ skillStatement = apiSkillUpdateV2.skillStatement,
+ publishStatus = apiSkillUpdateV2.publishStatus,
+ collections = apiSkillUpdateV2.collections,
+ authors =
+ getApiStringListUpdate(
+ apiSkillUpdateV2.author,
+ skillUUID,
+ richSkillRepository,
+ KeywordTypeEnum.Author,
+ ),
+ categories =
+ getApiStringListUpdate(
+ apiSkillUpdateV2.category,
+ skillUUID,
+ richSkillRepository,
+ KeywordTypeEnum.Category,
+ ),
)
- }
private fun getApiStringListUpdate(
newFieldValue: String?,
skillUUID: String,
richSkillRepository: RichSkillRepository,
- type: KeywordTypeEnum
+ type: KeywordTypeEnum,
): ApiStringListUpdate {
- val storedFieldValues = richSkillRepository.findByUUID(skillUUID)?.keywords?.filter { it.type == type }?.mapNotNull { it.value }
-
+ val storedFieldValues =
+ richSkillRepository
+ .findByUUID(skillUUID)
+ ?.keywords
+ ?.filter { it.type == type }
+ ?.mapNotNull { it.value }
+
return if (newFieldValue != null && storedFieldValues != null) {
- if(storedFieldValues.contains(newFieldValue)) {
+ if (storedFieldValues.contains(newFieldValue)) {
val sum = storedFieldValues + listOf(newFieldValue)
- val toBeRemoved = sum.groupBy { it }
- .filter { it.value.size == 1 }
- .flatMap { it.value }
+ val toBeRemoved =
+ sum
+ .groupBy { it }
+ .filter { it.value.size == 1 }
+ .flatMap { it.value }
ApiStringListUpdate(listOf(newFieldValue), toBeRemoved)
- }else {
+ } else {
ApiStringListUpdate(listOf(newFieldValue), storedFieldValues)
}
} else {
@@ -44,5 +61,4 @@ class ApiSkillUpdateMapper {
}
}
}
-
-}
\ No newline at end of file
+}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdateV2.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdateV2.kt
index 0aae4b1be..faf023c41 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdateV2.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillUpdateV2.kt
@@ -6,42 +6,30 @@ import edu.wgu.osmt.db.PublishStatus
data class ApiSkillUpdateV2(
@JsonProperty("skillName")
override val skillName: String? = null,
-
@JsonProperty("skillStatement")
override val skillStatement: String? = null,
-
@JsonProperty("status")
override val publishStatus: PublishStatus? = null,
-
@JsonProperty("collections")
override val collections: ApiStringListUpdate? = null,
-
@JsonProperty("author")
val author: String? = null,
-
@JsonProperty("category")
val category: String? = null,
-
@JsonProperty("keywords")
override val keywords: ApiStringListUpdate? = null,
-
@JsonProperty("certifications")
override val certifications: ApiReferenceListUpdate? = null,
-
@JsonProperty("standards")
override val standards: ApiAlignmentListUpdate? = null,
-
@JsonProperty("alignments")
override val alignments: ApiAlignmentListUpdate? = null,
-
@JsonProperty("employers")
override val employers: ApiReferenceListUpdate? = null,
-
@JsonProperty("occupations")
- override val occupations: ApiStringListUpdate? = null
+ override val occupations: ApiStringListUpdate? = null,
) : SkillUpdate {
-
- fun validate(rowNumber: Number? = null): List? {
+ fun validate(_rowNumber: Number? = null): List? {
val errors = mutableListOf()
return if (errors.size > 0) errors else null
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillV2.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillV2.kt
index 0369c6380..65f61fc29 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillV2.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkillV2.kt
@@ -9,11 +9,10 @@ import edu.wgu.osmt.richskill.RichSkillDescriptor
import edu.wgu.osmt.richskill.RichSkillDescriptorDao
class ApiSkillV2(
- @JsonIgnore override val rsd: RichSkillDescriptor,
- @JsonIgnore override val cs: Set,
- private val appConfig: AppConfig
-): ApiSkill(rsd, cs, appConfig) {
-
+ @JsonIgnore override val rsd: RichSkillDescriptor,
+ @JsonIgnore override val cs: Set,
+ private val appConfig: AppConfig,
+) : ApiSkill(rsd, cs, appConfig) {
@get:JsonIgnore
override val authors: List
get() = rsd.authors.mapNotNull { it.value }
@@ -24,26 +23,42 @@ class ApiSkillV2(
@get:JsonProperty
val author: String
- get() = rsd.authors.mapNotNull { it.value }.sorted().joinToString(SEMICOLON)
-
+ get() =
+ rsd.authors
+ .mapNotNull { it.value }
+ .sorted()
+ .joinToString(SEMICOLON)
+
@get:JsonProperty
val category: String
- get() = rsd.categories.mapNotNull { it.value }.sorted().joinToString(SEMICOLON)
+ get() =
+ rsd.categories
+ .mapNotNull { it.value }
+ .sorted()
+ .joinToString(SEMICOLON)
companion object {
- fun fromDao(rsdDao: RichSkillDescriptorDao, appConfig: AppConfig): ApiSkillV2 {
- return ApiSkillV2(rsdDao.toModel(), rsdDao.collections.map{ it.toModel() }.filter { !it.isWorkspace() }.toSet(), appConfig)
- }
+ fun fromDao(
+ rsdDao: RichSkillDescriptorDao,
+ appConfig: AppConfig,
+ ): ApiSkillV2 =
+ ApiSkillV2(
+ rsdDao.toModel(),
+ rsdDao.collections
+ .map { it.toModel() }
+ .filter { !it.isWorkspace() }
+ .toSet(),
+ appConfig,
+ )
- fun fromLatest(apiSkill: ApiSkill, appConfig: AppConfig): ApiSkillV2 {
- return ApiSkillV2(
- rsd = apiSkill.rsd,
- cs = apiSkill.cs,
- appConfig
+ fun fromLatest(
+ apiSkill: ApiSkill,
+ appConfig: AppConfig,
+ ): ApiSkillV2 =
+ ApiSkillV2(
+ rsd = apiSkill.rsd,
+ cs = apiSkill.cs,
+ appConfig,
)
- }
}
}
-
-
-
diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSortEnum.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSortEnum.kt
index 2f8821b39..ff63b1cfe 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSortEnum.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSortEnum.kt
@@ -7,7 +7,7 @@ import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.data.domain.Sort
-const val nameKeyword: String = "name.keyword";
+const val nameKeyword: String = "name.keyword"
interface SortOrder {
val apiValue: String
@@ -16,7 +16,7 @@ interface SortOrder {
val sort: Sort
}
-interface SortOrderCompanion where T: SortOrder{
+interface SortOrderCompanion where T : SortOrder {
val logger: Logger
val defaultSort: T
@@ -24,47 +24,52 @@ interface SortOrderCompanion where T: SortOrder{
val defaultSortValue: String
get() = defaultSort.apiValue
- fun forValueOrDefault(apiValue: String?, sort: T = defaultSort): T {
- return if (!apiValue.isNullOrEmpty()){
+ fun forValueOrDefault(
+ apiValue: String?,
+ sort: T = defaultSort,
+ ): T =
+ if (!apiValue.isNullOrEmpty()) {
forApiValue(apiValue)
} else {
sort
}
- }
fun forApiValue(apiValue: String): T
}
-
-
/**
* Provides an enum for Rich skills that defines elasticsearch sorting
*/
-enum class SkillSortEnum(override val apiValue: String) : SortOrder {
+enum class SkillSortEnum(
+ override val apiValue: String,
+) : SortOrder {
NameAsc(NAME_ASC) {
override val sort = Sort.by(NAME_SORT_INSENSITIVE).ascending()
},
NameDesc(NAME_DESC) {
override val sort = Sort.by(NAME_SORT_INSENSITIVE).descending()
- };
+ }, ;
companion object : SortOrderCompanion {
override val logger: Logger = LoggerFactory.getLogger(SkillSortEnum::class.java)
override val defaultSort = NameAsc
- override fun forApiValue(apiValue: String): SkillSortEnum {
- return values().find { it.apiValue == apiValue } ?: NameAsc.also {
- logger.warn("Sort with value ${apiValue} could not be found; using default ${NameAsc.apiValue} sort")
+ override fun forApiValue(apiValue: String): SkillSortEnum =
+ values().find { it.apiValue == apiValue } ?: NameAsc.also {
+ logger.warn(
+ "Sort with value $apiValue could not be found; using default ${NameAsc.apiValue} sort",
+ )
}
- }
}
}
/**
* Provides an enum for Collections that defines elasticsearch sorting
*/
-enum class CollectionSortEnum(override val apiValue: String) : SortOrder {
+enum class CollectionSortEnum(
+ override val apiValue: String,
+) : SortOrder {
SkillCountAsc("skill.asc") {
override val sort = Sort.by("skillCount").ascending()
},
@@ -76,25 +81,28 @@ enum class CollectionSortEnum(override val apiValue: String) : SortOrder {
},
CollectionNameDesc("name.desc") {
override val sort = Sort.by(nameKeyword).descending()
- };
+ }, ;
companion object : SortOrderCompanion {
override val logger: Logger = LoggerFactory.getLogger(CollectionSortEnum::class.java)
override val defaultSort = CollectionNameAsc
- override fun forApiValue(apiValue: String): CollectionSortEnum {
- return values().find { it.apiValue == apiValue } ?: CollectionNameAsc.also {
- logger.warn("Sort with value ${apiValue} could not be found; using default ${CollectionNameAsc.apiValue} sort")
+ override fun forApiValue(apiValue: String): CollectionSortEnum =
+ values().find { it.apiValue == apiValue } ?: CollectionNameAsc.also {
+ logger.warn(
+ "Sort with value $apiValue could not be found; using default ${CollectionNameAsc.apiValue} sort",
+ )
}
- }
}
}
/**
* Provides an enum for Keywords that defines elasticsearch sorting
*/
-enum class KeywordSortEnum(override val apiValue: String) : SortOrder {
+enum class KeywordSortEnum(
+ override val apiValue: String,
+) : SortOrder {
KeywordAsc("keyword.asc") {
override val sort = Sort.by("value").ascending()
},
@@ -106,17 +114,18 @@ enum class KeywordSortEnum(override val apiValue: String) : SortOrder {
},
SkillCountDesc("skillCount.desc") {
override val sort = Sort.by("skillCount").descending()
- };
+ }, ;
companion object : SortOrderCompanion {
override val logger: Logger = LoggerFactory.getLogger(KeywordSortEnum::class.java)
override val defaultSort = KeywordAsc
- override fun forApiValue(apiValue: String): KeywordSortEnum {
- return values().find { it.apiValue == apiValue } ?: KeywordAsc.also {
- logger.warn("Sort with value ${apiValue} could not be found; using default ${KeywordAsc.apiValue} sort")
+ override fun forApiValue(apiValue: String): KeywordSortEnum =
+ values().find { it.apiValue == apiValue } ?: KeywordAsc.also {
+ logger.warn(
+ "Sort with value $apiValue could not be found; using default ${KeywordAsc.apiValue} sort",
+ )
}
- }
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLog.kt b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLog.kt
index bcc41713e..9f5397d99 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLog.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLog.kt
@@ -10,78 +10,69 @@ import java.time.ZoneOffset
enum class AuditOperationType {
Insert,
Update,
- PublishStatusChange
+ PublishStatusChange,
}
data class Change(
val fieldName: String,
val old: String?,
- val new: String?
+ val new: String?,
) {
companion object {
fun maybeChange(
fieldName: String,
old: String?,
- new: String?
- ): Change? {
- return if (old != new) {
+ new: String?,
+ ): Change? =
+ if (old != new) {
Change(fieldName, old, new)
- } else null
- }
+ } else {
+ null
+ }
}
}
-fun List.findByFieldName(fieldName: String): Change? {
- return this.find{it.fieldName == fieldName}
-}
+fun List.findByFieldName(fieldName: String): Change? =
+ this.find {
+ it.fieldName == fieldName
+ }
data class AuditLog(
@JsonIgnore
override val id: Long?,
-
override val creationDate: LocalDateTime,
-
val operationType: String,
-
@JsonIgnore
val tableName: String,
-
@JsonIgnore
val entityId: Long,
-
val user: String,
-
- val changedFields: List
+ val changedFields: List,
) : DatabaseData {
-
companion object {
-
fun fromAtomicOp(
table: Table,
entityId: Long,
changes: List,
user: String,
- opType: AuditOperationType
- ): AuditLog {
- return AuditLog(
+ opType: AuditOperationType,
+ ): AuditLog =
+ AuditLog(
id = null,
creationDate = LocalDateTime.now(ZoneOffset.UTC),
operationType = opType.name,
tableName = table.tableName,
entityId = entityId,
user = user,
- changedFields = changes
+ changedFields = changes,
)
- }
fun fromAtomicOp(
table: Table,
entityId: Long,
changes: List,
user: OAuth2User,
- opType: AuditOperationType
- ): AuditLog {
- return fromAtomicOp(table, entityId, changes, user.name.toString(), opType)
- }
+ opType: AuditOperationType,
+ ): AuditLog = fromAtomicOp(table, entityId, changes, user.name.toString(), opType)
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogDao.kt b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogDao.kt
index de4d87432..1f2817c21 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogDao.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogDao.kt
@@ -7,7 +7,10 @@ import org.jetbrains.exposed.dao.LongEntity
import org.jetbrains.exposed.dao.LongEntityClass
import org.jetbrains.exposed.dao.id.EntityID
-class AuditLogDao(id: EntityID) : LongEntity(id), OutputsModel {
+class AuditLogDao(
+ id: EntityID,
+) : LongEntity(id),
+ OutputsModel {
companion object : LongEntityClass(AuditLogTable)
var creationDate by AuditLogTable.creationDate
@@ -20,6 +23,14 @@ class AuditLogDao(id: EntityID) : LongEntity(id), OutputsModel {
override fun toModel(): AuditLog {
val changeType = object : TypeToken>() {}.type
val deserializedChanges = Gson().fromJson>(changedFields, changeType)
- return AuditLog(id.value, creationDate, operationType, targetTableName, entityId, user, deserializedChanges)
+ return AuditLog(
+ id.value,
+ creationDate,
+ operationType,
+ targetTableName,
+ entityId,
+ user,
+ deserializedChanges,
+ )
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogRepository.kt b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogRepository.kt
index c2e87e0d0..4c9798074 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogRepository.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogRepository.kt
@@ -22,63 +22,81 @@ interface AuditLogRepository {
val dao: AuditLogDao.Companion
fun create(auditLog: AuditLog): AuditLogDao?
- fun findByTableAndId(tableName: String, entityId: Long, offsetPageable: OffsetPageable? = null): SizedIterable
+
+ fun findByTableAndId(
+ tableName: String,
+ entityId: Long,
+ offsetPageable: OffsetPageable? = null,
+ ): SizedIterable
}
@Repository
@Transactional
-class AuditLogRepositoryImpl @Autowired constructor(appConfig: AppConfig) : AuditLogRepository {
- override val table = AuditLogTable
- override val dao = AuditLogDao.Companion
+class AuditLogRepositoryImpl
+ @Autowired
+ constructor(
+ _appConfig: AppConfig,
+ ) : AuditLogRepository {
+ override val table = AuditLogTable
+ override val dao = AuditLogDao.Companion
- override fun create(auditLog: AuditLog): AuditLogDao? {
- if (auditLog.id != null) {
- return null
- }
+ override fun create(auditLog: AuditLog): AuditLogDao? {
+ if (auditLog.id != null) {
+ return null
+ }
- val auditLog = dao.new {
- this.entityId = auditLog.entityId
- this.creationDate = auditLog.creationDate
- this.changedFields = Gson().toJson(auditLog.changedFields)
- this.operationType = auditLog.operationType
- this.targetTableName = auditLog.tableName
- this.user = auditLog.user
+ val auditLogDao =
+ dao.new {
+ this.entityId = auditLog.entityId
+ this.creationDate = auditLog.creationDate
+ this.changedFields = Gson().toJson(auditLog.changedFields)
+ this.operationType = auditLog.operationType
+ this.targetTableName = auditLog.tableName
+ this.user = auditLog.user
+ }
+ return auditLogDao
}
- return auditLog
- }
- override fun findByTableAndId(tableName: String, entityId: Long, offsetPageable: OffsetPageable?): SizedIterable {
- val query = table.select {
- table.targetTableName eq tableName and (table.entityId eq entityId)
- }.orderBy(*table.sortAdapter(offsetPageable))
+ override fun findByTableAndId(
+ tableName: String,
+ entityId: Long,
+ offsetPageable: OffsetPageable?,
+ ): SizedIterable {
+ val query =
+ table
+ .select {
+ table.targetTableName eq tableName and (table.entityId eq entityId)
+ }.orderBy(*table.sortAdapter(offsetPageable))
- return if (offsetPageable != null){
- dao.wrapRows(query).limit(offsetPageable.limit, offsetPageable.offset.toLong())
- } else {
- dao.wrapRows(query)
+ return if (offsetPageable != null) {
+ dao.wrapRows(query).limit(offsetPageable.limit, offsetPageable.offset.toLong())
+ } else {
+ dao.wrapRows(query)
+ }
}
}
-}
-enum class AuditLogSortEnum(override val apiValue: String): SortOrder {
- DateAsc("date.asc"){
+enum class AuditLogSortEnum(
+ override val apiValue: String,
+) : SortOrder {
+ DateAsc("date.asc") {
override val sort = Sort.by("creationDate").ascending()
},
- DateDesc("date.desc"){
+ DateDesc("date.desc") {
override val sort = Sort.by("creationDate").descending()
- }
- ;
+ }, ;
- companion object : SortOrderCompanion{
+ companion object : SortOrderCompanion {
override val logger: Logger = LoggerFactory.getLogger(SkillSortEnum::class.java)
override val defaultSort = AuditLogSortEnum.DateAsc
- override fun forApiValue(apiValue: String): AuditLogSortEnum {
- return AuditLogSortEnum.values().find { it.apiValue == apiValue } ?: DateAsc.also {
- logger.warn("Sort with value ${apiValue} could not be found; using default ${DateAsc.apiValue} sort")
+ override fun forApiValue(apiValue: String): AuditLogSortEnum =
+ AuditLogSortEnum.values().find { it.apiValue == apiValue } ?: DateAsc.also {
+ logger.warn(
+ "Sort with value $apiValue could not be found; using default ${DateAsc.apiValue} sort",
+ )
}
- }
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogUtils.kt b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogUtils.kt
index c62568f79..7f6201b25 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogUtils.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogUtils.kt
@@ -44,8 +44,8 @@ class AuditLogUtils {
skill.id.value,
changes,
BatchImportRichSkill.user,
- AuditOperationType.Insert
- )
+ AuditOperationType.Insert,
+ ),
)
}
}
@@ -60,8 +60,8 @@ class AuditLogUtils {
collection.id.value,
changes,
BatchImportRichSkill.user,
- AuditOperationType.Insert
- )
+ AuditOperationType.Insert,
+ ),
)
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/auditlog/Comparison.kt b/api/src/main/kotlin/edu/wgu/osmt/auditlog/Comparison.kt
index 17cfa3190..7364e3a2c 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/auditlog/Comparison.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/auditlog/Comparison.kt
@@ -4,12 +4,19 @@ import kotlin.reflect.KFunction1
const val DELIMITER = "; "
-data class Comparison(val fieldName: String, val function: KFunction1, val old: R?, val new: R?) {
+data class Comparison(
+ val fieldName: String,
+ val function: KFunction1,
+ val old: R?,
+ val new: R?,
+) {
fun compare(): Change? {
val oldValue: String? = old?.let { function(it) }
val newValue: String? = new?.let { function(it) }
return if (oldValue != newValue) {
Change.maybeChange(fieldName, oldValue, newValue)
- } else null
+ } else {
+ null
+ }
}
}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/Collection.kt b/api/src/main/kotlin/edu/wgu/osmt/collection/Collection.kt
index cbfb6e258..131e21b30 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/collection/Collection.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/collection/Collection.kt
@@ -2,7 +2,14 @@ package edu.wgu.osmt.collection
import edu.wgu.osmt.auditlog.Change
import edu.wgu.osmt.auditlog.Comparison
-import edu.wgu.osmt.db.*
+import edu.wgu.osmt.db.DatabaseData
+import edu.wgu.osmt.db.HasPublishStatus
+import edu.wgu.osmt.db.HasUpdateDate
+import edu.wgu.osmt.db.ListFieldUpdate
+import edu.wgu.osmt.db.NullableFieldUpdate
+import edu.wgu.osmt.db.PublishStatus
+import edu.wgu.osmt.db.PublishStatusDetails
+import edu.wgu.osmt.db.UpdateObject
import edu.wgu.osmt.keyword.Keyword
import edu.wgu.osmt.keyword.KeywordDao
import edu.wgu.osmt.keyword.KeywordTypeEnum
@@ -25,14 +32,17 @@ data class Collection(
val workspaceOwner: String? = null,
val status: PublishStatus,
override val archiveDate: LocalDateTime? = null,
- override val publishDate: LocalDateTime? = null
-) : DatabaseData, HasUpdateDate, PublishStatusDetails {
-
- fun canonicalUrl(baseUrl: String): String = "$baseUrl/api/collections/${uuid}"
-
- fun isWorkspace() : Boolean {
- return (this.status == PublishStatus.Workspace && StringUtils.isNotEmpty(this.workspaceOwner))
- }
+ override val publishDate: LocalDateTime? = null,
+) : DatabaseData,
+ HasUpdateDate,
+ PublishStatusDetails {
+ fun canonicalUrl(baseUrl: String): String = "$baseUrl/api/collections/$uuid"
+
+ fun isWorkspace(): Boolean =
+ (
+ this.status == PublishStatus.Workspace &&
+ StringUtils.isNotEmpty(this.workspaceOwner)
+ )
}
data class CollectionUpdateObject(
@@ -41,8 +51,9 @@ data class CollectionUpdateObject(
val description: NullableFieldUpdate? = null,
val author: NullableFieldUpdate? = null,
val skills: ListFieldUpdate? = null,
- override val publishStatus: PublishStatus? = null
-) : UpdateObject, HasPublishStatus {
+ override val publishStatus: PublishStatus? = null,
+) : UpdateObject,
+ HasPublishStatus {
init {
validate(this) {
validate(CollectionUpdateObject::author).validate {
@@ -53,7 +64,7 @@ data class CollectionUpdateObject(
}
}
- override fun applyToDao(dao: CollectionDao): Unit{
+ override fun applyToDao(dao: CollectionDao) {
dao.updateDate = LocalDateTime.now(ZoneOffset.UTC)
applyStatusChange(dao)
name?.let { dao.name = it }
@@ -78,37 +89,41 @@ data class CollectionUpdateObject(
fun applyStatusChange(dao: CollectionDao) {
when (publishStatus) {
- PublishStatus.Archived -> {
+ PublishStatus.Archived -> {
dao.archiveDate = LocalDateTime.now(ZoneOffset.UTC)
dao.status = PublishStatus.Archived
}
+
PublishStatus.Published -> {
dao.publishDate = LocalDateTime.now(ZoneOffset.UTC)
dao.status = PublishStatus.Published
dao.archiveDate = null
}
+
PublishStatus.Unarchived -> {
if (dao.publishDate != null) {
dao.status = PublishStatus.Published
-
} else {
dao.status = PublishStatus.Draft
}
dao.archiveDate = null
}
+
PublishStatus.Draft -> {
dao.status = PublishStatus.Draft
dao.archiveDate = null
dao.publishDate = null
}
+
PublishStatus.Workspace -> {
dao.status = PublishStatus.Workspace
}
+
else -> {}
}
}
- fun applySkills(): CollectionUpdateObject{
+ fun applySkills(): CollectionUpdateObject {
skills?.let {
it.add?.forEach { skill ->
CollectionSkills.create(collectionId = id!!, skillId = skill.id.value)
@@ -124,33 +139,41 @@ data class CollectionUpdateObject(
fun Collection.diff(old: Collection?): List {
val new = this
- old?.let{if (it.uuid != new.uuid) throw Exception("Tried to compare different UUIDs, ${it.uuid} != ${new.uuid}")}
+ old?.let {
+ if (it.uuid !=
+ new.uuid
+ ) {
+ throw Exception("Tried to compare different UUIDs, ${it.uuid} != ${new.uuid}")
+ }
+ }
- val comparisons: List> = listOf(
- Comparison(Collection::name.name, CollectionComparison::compareName, old, new),
- Comparison(Collection::description.name, CollectionComparison::compareDescription, old, new),
- Comparison(Collection::author.name, CollectionComparison::compareAuthor, old, new),
- Comparison(Collection::publishStatus.name, CollectionComparison::comparePublishStatus, old, new)
- )
+ val comparisons: List> =
+ listOf(
+ Comparison(Collection::name.name, CollectionComparison::compareName, old, new),
+ Comparison(
+ Collection::description.name,
+ CollectionComparison::compareDescription,
+ old,
+ new,
+ ),
+ Comparison(Collection::author.name, CollectionComparison::compareAuthor, old, new),
+ Comparison(
+ Collection::publishStatus.name,
+ CollectionComparison::comparePublishStatus,
+ old,
+ new,
+ ),
+ )
return comparisons.mapNotNull { it.compare() }
}
object CollectionComparison {
- fun compareName(c: Collection): String {
- return c.name
- }
+ fun compareName(c: Collection): String = c.name
- fun compareDescription(c: Collection): String? {
- return c.description
- }
+ fun compareDescription(c: Collection): String? = c.description
- fun compareAuthor(c: Collection): String? {
- return c.author?.value
- }
+ fun compareAuthor(c: Collection): String? = c.author?.value
- fun comparePublishStatus(c: Collection): String {
- return c.publishStatus().name
- }
+ fun comparePublishStatus(c: Collection): String = c.publishStatus().name
}
-
diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt
index 02e5d28d2..9d7f0b20b 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt
@@ -3,17 +3,35 @@ package edu.wgu.osmt.collection
import edu.wgu.osmt.HasAllPaginated
import edu.wgu.osmt.RoutePaths
import edu.wgu.osmt.api.GeneralApiException
-import edu.wgu.osmt.api.model.*
+import edu.wgu.osmt.api.model.ApiCollection
+import edu.wgu.osmt.api.model.ApiCollectionUpdate
+import edu.wgu.osmt.api.model.ApiCollectionV2
+import edu.wgu.osmt.api.model.ApiSearch
+import edu.wgu.osmt.api.model.ApiSearchV2
+import edu.wgu.osmt.api.model.ApiSkillListUpdate
+import edu.wgu.osmt.api.model.ApiStringListUpdate
+import edu.wgu.osmt.api.model.CollectionSortEnum
import edu.wgu.osmt.auditlog.AuditLog
import edu.wgu.osmt.auditlog.AuditLogRepository
import edu.wgu.osmt.auditlog.AuditLogSortEnum
import edu.wgu.osmt.config.AppConfig
import edu.wgu.osmt.config.DEFAULT_WORKSPACE_NAME
+import edu.wgu.osmt.credentialengine.CredentialEngineUrlProvider
import edu.wgu.osmt.db.PublishStatus
import edu.wgu.osmt.elasticsearch.OffsetPageable
import edu.wgu.osmt.richskill.RichSkillRepository
import edu.wgu.osmt.security.OAuthHelper
-import edu.wgu.osmt.task.*
+import edu.wgu.osmt.task.AppliesToType
+import edu.wgu.osmt.task.CsvTask
+import edu.wgu.osmt.task.CsvTaskV2
+import edu.wgu.osmt.task.PublishTask
+import edu.wgu.osmt.task.PublishTaskV2
+import edu.wgu.osmt.task.RemoveCollectionSkillsTask
+import edu.wgu.osmt.task.Task
+import edu.wgu.osmt.task.TaskMessageService
+import edu.wgu.osmt.task.TaskResult
+import edu.wgu.osmt.task.UpdateCollectionSkillsTask
+import edu.wgu.osmt.task.XlsxTask
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.HttpEntity
import org.springframework.http.HttpStatus
@@ -24,349 +42,544 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Controller
import org.springframework.transaction.annotation.Transactional
-import org.springframework.web.bind.annotation.*
+import org.springframework.web.bind.annotation.DeleteMapping
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.util.UriComponentsBuilder
@Controller
@Transactional
-class CollectionController @Autowired constructor(
- val collectionRepository: CollectionRepository,
- val richSkillRepository: RichSkillRepository,
- val taskMessageService: TaskMessageService,
- val auditLogRepository: AuditLogRepository,
- val collectionEsRepo: CollectionEsRepo,
- val appConfig: AppConfig,
- val oAuthHelper: OAuthHelper
-) : HasAllPaginated {
- override val elasticRepository = collectionEsRepo
- override val allPaginatedPath: String = "${RoutePaths.API_V3}${RoutePaths.COLLECTIONS_LIST}"
- override val sortOrderCompanion = CollectionSortEnum.Companion
-
- @GetMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTIONS_LIST}",
- "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTIONS_LIST}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTIONS_LIST}"
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- override fun allPaginated(
- uriComponentsBuilder: UriComponentsBuilder,
- size: Int,
- from: Int,
- status: Array,
- sort: String?,
- @AuthenticationPrincipal user: Jwt?
- ): HttpEntity> {
- if (!appConfig.allowPublicLists && user === null) {
- throw GeneralApiException("Unauthorized", HttpStatus.UNAUTHORIZED)
+class CollectionController
+ @Autowired
+ constructor(
+ val collectionRepository: CollectionRepository,
+ val richSkillRepository: RichSkillRepository,
+ val taskMessageService: TaskMessageService,
+ val auditLogRepository: AuditLogRepository,
+ val collectionEsRepo: CollectionEsRepo,
+ val appConfig: AppConfig,
+ val oAuthHelper: OAuthHelper,
+ val credentialEngineUrlProvider: CredentialEngineUrlProvider,
+ ) : HasAllPaginated {
+ override val elasticRepository = collectionEsRepo
+ override val allPaginatedPath: String = "${RoutePaths.API_V3}${RoutePaths.COLLECTIONS_LIST}"
+ override val sortOrderCompanion = CollectionSortEnum.Companion
+
+ @GetMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTIONS_LIST}",
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTIONS_LIST}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTIONS_LIST}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ override fun allPaginated(
+ uriComponentsBuilder: UriComponentsBuilder,
+ size: Int,
+ from: Int,
+ status: Array,
+ sort: String?,
+ @AuthenticationPrincipal user: Jwt?,
+ ): HttpEntity> {
+ if (!appConfig.allowPublicLists && user === null) {
+ throw GeneralApiException("Unauthorized", HttpStatus.UNAUTHORIZED)
+ }
+ return super.allPaginated(uriComponentsBuilder, size, from, status, sort, user)
}
- return super.allPaginated(uriComponentsBuilder, size, from, status, sort, user)
- }
-
- @GetMapping("${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_DETAIL}", produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun byUUID(@PathVariable uuid: String): ApiCollection? {
- return collectionRepository.findByUUID(uuid)?.let {
- ApiCollection.fromDao(it, appConfig)
+
+ @GetMapping(
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_DETAIL}",
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun byUUID(
+ @PathVariable uuid: String,
+ ): ApiCollection? =
+ collectionRepository.findByUUID(uuid)?.let {
+ ApiCollection.fromDao(it, appConfig).also { coll ->
+ if (it.status in listOf(PublishStatus.Published, PublishStatus.Archived)) {
+ coll.credentialEngineUrl =
+ credentialEngineUrlProvider.collectionFinderUrl(it.uuid.toString())
+ }
+ }
+ }
+ ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
+
+ @GetMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_DETAIL}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_DETAIL}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun byUUIDV2(
+ @PathVariable uuid: String,
+ ): ApiCollection? =
+ collectionRepository.findByUUID(uuid)?.let {
+ byUUID(uuid)?.let { ac -> ApiCollectionV2.fromLatest(ac, appConfig) }
+ }
+ ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
+
+ @RequestMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_DETAIL}",
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_DETAIL}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_DETAIL}",
+ ],
+ produces = [MediaType.TEXT_HTML_VALUE],
+ )
+ fun byUUIDHtmlView(
+ @PathVariable uuid: String,
+ ): String = "forward:${RoutePaths.API_V3}/collections/$uuid"
+
+ @PostMapping(
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_CREATE}",
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun createCollections(
+ @RequestBody apiCollectionUpdates: List,
+ @AuthenticationPrincipal user: Jwt?,
+ ): List =
+ collectionRepository
+ .createFromApi(
+ apiCollectionUpdates,
+ richSkillRepository,
+ oAuthHelper.readableUserName(user),
+ oAuthHelper.readableUserIdentifier(user),
+ ).map {
+ ApiCollection.fromDao(it, appConfig).also { coll ->
+ if (it.status in
+ listOf(
+ PublishStatus.Published,
+ PublishStatus.Archived,
+ )
+ ) {
+ coll.credentialEngineUrl =
+ credentialEngineUrlProvider.collectionFinderUrl(it.uuid.toString())
+ }
+ }
+ }
+
+ @PostMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_CREATE}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_CREATE}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun createCollectionsV2(
+ @RequestBody apiCollectionUpdates: List,
+ @AuthenticationPrincipal user: Jwt?,
+ ): List =
+ createCollections(apiCollectionUpdates, user).map {
+ ApiCollectionV2.fromLatest(it, appConfig)
+ }
+
+ @PostMapping(
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_DUPLICATE}",
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun duplicateCollection(
+ @PathVariable uuid: String,
+ @RequestBody apiUpdate: ApiCollectionUpdate,
+ @AuthenticationPrincipal user: Jwt?,
+ ): ApiCollection {
+ val duplicate =
+ collectionRepository.duplicateCollection(
+ uuid,
+ apiUpdate,
+ richSkillRepository,
+ oAuthHelper.readableUserName(user),
+ oAuthHelper.readableUserIdentifier(user),
+ ) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
+
+ return ApiCollection.fromDao(duplicate, appConfig).also { coll ->
+ if (duplicate.status in
+ listOf(
+ PublishStatus.Published,
+ PublishStatus.Archived,
+ )
+ ) {
+ coll.credentialEngineUrl =
+ credentialEngineUrlProvider.collectionFinderUrl(
+ duplicate.uuid.toString(),
+ )
+ }
+ }
}
- ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
- }
-
- @GetMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_DETAIL}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_DETAIL}"],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun byUUIDV2(@PathVariable uuid: String): ApiCollection? {
- return collectionRepository.findByUUID(uuid)?.let {
- byUUID(uuid)?.let { ac -> ApiCollectionV2.fromLatest(ac, appConfig) }
+
+ @PostMapping(
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_UPDATE}",
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun updateCollection(
+ @PathVariable uuid: String,
+ @RequestBody apiUpdate: ApiCollectionUpdate,
+ @AuthenticationPrincipal user: Jwt?,
+ ): ApiCollection {
+ if (oAuthHelper.hasRole(appConfig.roleCurator) &&
+ !oAuthHelper.isArchiveRelated(apiUpdate.publishStatus)
+ ) {
+ throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
+ }
+
+ val existing =
+ collectionRepository.findByUUID(uuid)
+ ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
+
+ val updated =
+ collectionRepository.updateFromApi(
+ existing.id.value,
+ apiUpdate,
+ richSkillRepository,
+ oAuthHelper.readableUserName(user),
+ )
+ ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
+
+ return ApiCollection.fromDao(updated, appConfig).also { coll ->
+ if (updated.status in
+ listOf(
+ PublishStatus.Published,
+ PublishStatus.Archived,
+ )
+ ) {
+ coll.credentialEngineUrl =
+ credentialEngineUrlProvider.collectionFinderUrl(updated.uuid.toString())
+ }
+ }
}
- ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
- }
-
- @RequestMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_DETAIL}",
- "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_DETAIL}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_DETAIL}"
- ],
- produces = [MediaType.TEXT_HTML_VALUE])
- fun byUUIDHtmlView(@PathVariable uuid: String): String {
-
- return "forward:${RoutePaths.API_V3}/collections/$uuid"
- }
-
- @PostMapping("${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_CREATE}", produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun createCollections(
- @RequestBody apiCollectionUpdates: List,
- @AuthenticationPrincipal user: Jwt?
- ): List {
- return collectionRepository.createFromApi(
- apiCollectionUpdates,
- richSkillRepository,
- oAuthHelper.readableUserName(user),
- oAuthHelper.readableUserIdentifier(user)
- ).map {
- ApiCollection.fromDao(it, appConfig)
+
+ @PostMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_UPDATE}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_UPDATE}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun updateCollectionV2(
+ @PathVariable uuid: String,
+ @RequestBody apiUpdate: ApiCollectionUpdate,
+ @AuthenticationPrincipal user: Jwt?,
+ ): ApiCollection = ApiCollectionV2.fromLatest(updateCollection(uuid, apiUpdate, user), appConfig)
+
+ @PostMapping(
+ path = [
+ "${RoutePaths.API}/{apiVersion}${RoutePaths.COLLECTION_SKILLS_UPDATE}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun updateSkills(
+ @PathVariable(name = "apiVersion", required = false) apiVersion: String?,
+ @PathVariable uuid: String,
+ @RequestBody skillListUpdate: ApiSkillListUpdate,
+ @RequestParam(
+ required = false,
+ defaultValue = PublishStatus.DEFAULT_API_PUBLISH_STATUS_SET,
+ ) status: List,
+ @AuthenticationPrincipal user: Jwt?,
+ ): HttpEntity {
+ val publishStatuses = status.mapNotNull { PublishStatus.forApiValue(it) }.toSet()
+
+ return if (RoutePaths.API_V3 == "/$apiVersion") {
+ val task =
+ UpdateCollectionSkillsTask(
+ uuid,
+ skillListUpdate,
+ publishStatuses = publishStatuses,
+ userString = oAuthHelper.readableUserName(user),
+ apiResultPath =
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.TASK_DETAIL_BATCH}",
+ )
+ taskMessageService.enqueueJob(TaskMessageService.updateCollectionSkills, task)
+ Task.processingResponse(task)
+ } else if (RoutePaths.API_V2 == "/$apiVersion" ||
+ RoutePaths.UNVERSIONED == apiVersion
+ ) {
+ val task =
+ UpdateCollectionSkillsTask(
+ uuid,
+ skillListUpdate,
+ publishStatuses = publishStatuses,
+ userString = oAuthHelper.readableUserName(user),
+ apiResultPath =
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.TASK_DETAIL_BATCH}",
+ )
+ taskMessageService.enqueueJob(TaskMessageService.updateCollectionSkills, task)
+ Task.processingResponse(task)
+ } else {
+ throw ResponseStatusException(HttpStatus.NOT_FOUND)
+ }
}
- }
-
- @PostMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_CREATE}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_CREATE}"
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun createCollectionsV2(
- @RequestBody apiCollectionUpdates: List,
- @AuthenticationPrincipal user: Jwt?
- ): List {
- return createCollections(apiCollectionUpdates, user).map { ApiCollectionV2.fromLatest(it, appConfig) }
- }
-
-
- @PostMapping("${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_UPDATE}", produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun updateCollection(
- @PathVariable uuid: String,
- @RequestBody apiUpdate: ApiCollectionUpdate,
- @AuthenticationPrincipal user: Jwt?
- ): ApiCollection {
-
- if (oAuthHelper.hasRole(appConfig.roleCurator) && !oAuthHelper.isArchiveRelated(apiUpdate.publishStatus)) {
- throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
+
+ @PostMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_PUBLISH}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun publishCollections(
+ @RequestBody search: ApiSearch,
+ @RequestParam(
+ required = false,
+ defaultValue = "Published",
+ ) newStatus: String,
+ @RequestParam(
+ required = false,
+ defaultValue = PublishStatus.DEFAULT_API_PUBLISH_STATUS_SET,
+ ) filterByStatus: List,
+ @AuthenticationPrincipal user: Jwt?,
+ ): HttpEntity {
+ val filterStatuses = filterByStatus.mapNotNull { PublishStatus.forApiValue(it) }.toSet()
+ val publishStatus =
+ PublishStatus.forApiValue(newStatus)
+ ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST)
+ val task =
+ PublishTask(
+ AppliesToType.Collection,
+ search,
+ filterByStatus = filterStatuses,
+ publishStatus = publishStatus,
+ userString = oAuthHelper.readableUserName(user),
+ )
+ taskMessageService.enqueueJob(TaskMessageService.publishSkills, task)
+
+ return Task.processingResponse(task)
}
-
- val existing = collectionRepository.findByUUID(uuid)
- ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
-
- val updated = collectionRepository.updateFromApi(
- existing.id.value,
- apiUpdate,
- richSkillRepository, oAuthHelper.readableUserName(user)
+
+ @PostMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_PUBLISH}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_PUBLISH}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
)
- ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
-
- return ApiCollection.fromDao(updated, appConfig)
- }
-
- @PostMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_UPDATE}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_UPDATE}",
- ], produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun updateCollectionV2(
- @PathVariable uuid: String,
- @RequestBody apiUpdate: ApiCollectionUpdate,
- @AuthenticationPrincipal user: Jwt?
- ): ApiCollection {
-
- return ApiCollectionV2.fromLatest(updateCollection(uuid, apiUpdate, user), appConfig)
- }
-
- @PostMapping(path = [
- "${RoutePaths.API}/{apiVersion}${RoutePaths.COLLECTION_SKILLS_UPDATE}"
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun updateSkills(
- @PathVariable(name = "apiVersion", required = false) apiVersion: String?,
- @PathVariable uuid: String,
- @RequestBody skillListUpdate: ApiSkillListUpdate,
- @RequestParam(
- required = false,
- defaultValue = PublishStatus.DEFAULT_API_PUBLISH_STATUS_SET
- ) status: List,
- @AuthenticationPrincipal user: Jwt?
- ): HttpEntity {
- val publishStatuses = status.mapNotNull { PublishStatus.forApiValue(it) }.toSet()
-
- return if (RoutePaths.API_V3 == "/${apiVersion}") {
- val task = UpdateCollectionSkillsTask(uuid, skillListUpdate, publishStatuses = publishStatuses, userString = oAuthHelper.readableUserName(user), apiResultPath = "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.TASK_DETAIL_BATCH}")
- taskMessageService.enqueueJob(TaskMessageService.updateCollectionSkills, task)
- Task.processingResponse(task)
- } else if (RoutePaths.API_V2 == "/${apiVersion}" || RoutePaths.UNVERSIONED == apiVersion) {
- val task = UpdateCollectionSkillsTask(uuid, skillListUpdate, publishStatuses = publishStatuses, userString = oAuthHelper.readableUserName(user), apiResultPath = "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.TASK_DETAIL_BATCH}")
- taskMessageService.enqueueJob(TaskMessageService.updateCollectionSkills, task)
- Task.processingResponse(task)
- } else {
- throw ResponseStatusException(HttpStatus.NOT_FOUND)
+ @ResponseBody
+ fun publishCollectionsV2(
+ @RequestBody search: ApiSearchV2,
+ @RequestParam(
+ required = false,
+ defaultValue = "Published",
+ ) newStatus: String,
+ @RequestParam(
+ required = false,
+ defaultValue = PublishStatus.DEFAULT_API_PUBLISH_STATUS_SET,
+ ) filterByStatus: List,
+ @AuthenticationPrincipal user: Jwt?,
+ ): HttpEntity {
+ val filterStatuses = filterByStatus.mapNotNull { PublishStatus.forApiValue(it) }.toSet()
+ val publishStatus =
+ PublishStatus.forApiValue(newStatus)
+ ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST)
+ val task =
+ PublishTaskV2(
+ AppliesToType.Collection,
+ search,
+ filterByStatus = filterStatuses,
+ publishStatus = publishStatus,
+ userString = oAuthHelper.readableUserName(user),
+ )
+ taskMessageService.enqueueJob(TaskMessageService.publishSkills, task)
+
+ return Task.processingResponse(task)
}
- }
-
- @PostMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_PUBLISH}",
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun publishCollections(
- @RequestBody search: ApiSearch,
- @RequestParam(
- required = false,
- defaultValue = "Published"
- ) newStatus: String,
- @RequestParam(
- required = false,
- defaultValue = PublishStatus.DEFAULT_API_PUBLISH_STATUS_SET
- ) filterByStatus: List,
- @AuthenticationPrincipal user: Jwt?
- ): HttpEntity {
- val filterStatuses = filterByStatus.mapNotNull { PublishStatus.forApiValue(it) }.toSet()
- val publishStatus = PublishStatus.forApiValue(newStatus)
- ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST)
- val task = PublishTask(AppliesToType.Collection, search, filterByStatus = filterStatuses, publishStatus = publishStatus, userString = oAuthHelper.readableUserName(user))
- taskMessageService.enqueueJob(TaskMessageService.publishSkills, task)
-
- return Task.processingResponse(task)
- }
-
- @PostMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_PUBLISH}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_PUBLISH}"
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun publishCollectionsV2(
- @RequestBody search: ApiSearchV2,
- @RequestParam(
- required = false,
- defaultValue = "Published"
- ) newStatus: String,
- @RequestParam(
- required = false,
- defaultValue = PublishStatus.DEFAULT_API_PUBLISH_STATUS_SET
- ) filterByStatus: List,
- @AuthenticationPrincipal user: Jwt?
- ): HttpEntity {
- val filterStatuses = filterByStatus.mapNotNull { PublishStatus.forApiValue(it) }.toSet()
- val publishStatus = PublishStatus.forApiValue(newStatus)
- ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST)
- val task = PublishTaskV2(AppliesToType.Collection, search, filterByStatus = filterStatuses, publishStatus = publishStatus, userString = oAuthHelper.readableUserName(user))
- taskMessageService.enqueueJob(TaskMessageService.publishSkills, task)
-
- return Task.processingResponse(task)
- }
-
- @GetMapping(path = [
- "${RoutePaths.API}/{apiVersion}${RoutePaths.COLLECTION_CSV}"
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- fun getSkillsForCollectionCsv(
- @PathVariable(name = "apiVersion", required = false) apiVersion: String?,
- @PathVariable uuid: String
- ): HttpEntity {
- if (collectionRepository.findByUUID(uuid)!!.status == PublishStatus.Draft && !oAuthHelper.hasRole(appConfig.roleAdmin)) {
- throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
+
+ @GetMapping(
+ path = [
+ "${RoutePaths.API}/{apiVersion}${RoutePaths.COLLECTION_CSV}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ fun getSkillsForCollectionCsv(
+ @PathVariable(name = "apiVersion", required = false) apiVersion: String?,
+ @PathVariable uuid: String,
+ ): HttpEntity {
+ if (collectionRepository.findByUUID(uuid)!!.status == PublishStatus.Draft &&
+ !oAuthHelper.hasRole(appConfig.roleAdmin)
+ ) {
+ throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
+ }
+
+ return if (RoutePaths.API_V3 == "/$apiVersion") {
+ val task = CsvTask(collectionUuid = uuid)
+ taskMessageService.enqueueJob(TaskMessageService.skillsForCollectionCsv, task)
+ Task.processingResponse(task)
+ } else if (RoutePaths.API_V2 == "/$apiVersion" ||
+ RoutePaths.UNVERSIONED == apiVersion
+ ) {
+ val task = CsvTaskV2(collectionUuid = uuid)
+ taskMessageService.enqueueJob(TaskMessageService.skillsForCollectionCsvV2, task)
+ Task.processingResponse(task)
+ } else {
+ throw ResponseStatusException(HttpStatus.NOT_FOUND)
+ }
}
-
- return if (RoutePaths.API_V3 == "/${apiVersion}") {
- val task = CsvTask(collectionUuid = uuid)
- taskMessageService.enqueueJob(TaskMessageService.skillsForCollectionCsv, task)
- Task.processingResponse(task)
- } else if (RoutePaths.API_V2 == "/${apiVersion}" || RoutePaths.UNVERSIONED == apiVersion) {
- val task = CsvTaskV2(collectionUuid = uuid)
- taskMessageService.enqueueJob(TaskMessageService.skillsForCollectionCsvV2, task)
- Task.processingResponse(task)
- } else {
- throw ResponseStatusException(HttpStatus.NOT_FOUND)
+
+ @GetMapping(
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_XLSX}",
+ produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE],
+ )
+ fun getSkillsForCollectionXlsx(
+ @PathVariable uuid: String,
+ ): HttpEntity {
+ if (collectionRepository.findByUUID(uuid)!!.status == PublishStatus.Draft &&
+ !oAuthHelper.hasRole(appConfig.roleAdmin)
+ ) {
+ throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
+ }
+ val task = XlsxTask(collectionUuid = uuid)
+ taskMessageService.enqueueJob(TaskMessageService.skillsForCollectionXlsx, task)
+
+ return Task.processingResponse(task)
}
- }
-
- @GetMapping("${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_XLSX}", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
- fun getSkillsForCollectionXlsx(
- @PathVariable uuid: String
- ): HttpEntity {
- if (collectionRepository.findByUUID(uuid)!!.status == PublishStatus.Draft && !oAuthHelper.hasRole(appConfig.roleAdmin)) {
- throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
+
+ @DeleteMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_REMOVE}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ fun removeCollection(
+ @PathVariable uuid: String,
+ ): HttpEntity {
+ val task =
+ RemoveCollectionSkillsTask(
+ collectionUuid = uuid,
+ apiResultPath =
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.TASK_DETAIL_BATCH}",
+ )
+ taskMessageService.enqueueJob(TaskMessageService.removeCollectionSkills, task)
+
+ return Task.processingResponse(task)
}
- val task = XlsxTask(collectionUuid = uuid)
- taskMessageService.enqueueJob(TaskMessageService.skillsForCollectionXlsx, task)
-
- return Task.processingResponse(task)
- }
-
- @DeleteMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_REMOVE}",
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- fun removeCollection(
- @PathVariable uuid: String
- ): HttpEntity {
- val task = RemoveCollectionSkillsTask(collectionUuid = uuid, apiResultPath = "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.TASK_DETAIL_BATCH}")
- taskMessageService.enqueueJob(TaskMessageService.removeCollectionSkills, task)
-
- return Task.processingResponse(task)
- }
- @DeleteMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_REMOVE}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_REMOVE}"
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- fun removeCollectionV2(
- @PathVariable uuid: String
- ): HttpEntity {
- val task = RemoveCollectionSkillsTask(collectionUuid = uuid, apiResultPath = "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.TASK_DETAIL_BATCH}")
- taskMessageService.enqueueJob(TaskMessageService.removeCollectionSkills, task)
-
- return Task.processingResponse(task)
- }
-
- @GetMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_AUDIT_LOG}",
- "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_AUDIT_LOG}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_AUDIT_LOG}"
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- fun collectionAuditLog(
- @PathVariable uuid: String
- ): HttpEntity> {
- val pageable = OffsetPageable(0, Int.MAX_VALUE, AuditLogSortEnum.forValueOrDefault(AuditLogSortEnum.DateDesc.apiValue).sort)
- val collection = collectionRepository.findByUUID(uuid) ?: throw ResponseStatusException(NOT_FOUND, "Collection with id $uuid not ready or not found")
- val sizedIterable = auditLogRepository.findByTableAndId(CollectionTable.tableName, entityId = collection!!.id.value, offsetPageable = pageable)
-
- return ResponseEntity.status(200).body(sizedIterable.toList().map { it.toModel() })
- }
-
- @GetMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.WORKSPACE_PATH}",
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun getOrCreateWorkspace(
- @AuthenticationPrincipal user: Jwt?
- ): ApiCollection? {
-
- return collectionRepository.findByOwner(
- oAuthHelper.readableUserIdentifier(user))?.let {
- ApiCollection.fromDao(it, appConfig
- )
- } ?: collectionRepository.createFromApi(
- listOf(
- ApiCollectionUpdate(
- name = DEFAULT_WORKSPACE_NAME,
- publishStatus = PublishStatus.Workspace,
- author = oAuthHelper.readableUserName(user),
- skills = ApiStringListUpdate()
+ @DeleteMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_REMOVE}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_REMOVE}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ fun removeCollectionV2(
+ @PathVariable uuid: String,
+ ): HttpEntity {
+ val task =
+ RemoveCollectionSkillsTask(
+ collectionUuid = uuid,
+ apiResultPath =
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.TASK_DETAIL_BATCH}",
)
- ),
- richSkillRepository,
- oAuthHelper.readableUserName(user),
- oAuthHelper.readableUserIdentifier(user)
- ).firstOrNull()?.let { ApiCollection.fromDao(it, appConfig) }
- }
-
- @GetMapping(path = [
- "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.WORKSPACE_PATH}",
- "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.WORKSPACE_PATH}"
- ],
- produces = [MediaType.APPLICATION_JSON_VALUE])
- @ResponseBody
- fun getOrCreateWorkspaceV2(
- @AuthenticationPrincipal user: Jwt?
- ): ApiCollection? {
-
- return getOrCreateWorkspace(user)?.let { ApiCollectionV2.fromLatest(it, appConfig) }
+ taskMessageService.enqueueJob(TaskMessageService.removeCollectionSkills, task)
+
+ return Task.processingResponse(task)
+ }
+
+ @GetMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.COLLECTION_AUDIT_LOG}",
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.COLLECTION_AUDIT_LOG}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.COLLECTION_AUDIT_LOG}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ fun collectionAuditLog(
+ @PathVariable uuid: String,
+ ): HttpEntity> {
+ val pageable =
+ OffsetPageable(
+ 0,
+ Int.MAX_VALUE,
+ AuditLogSortEnum.forValueOrDefault(AuditLogSortEnum.DateDesc.apiValue).sort,
+ )
+ val collection =
+ collectionRepository.findByUUID(uuid)
+ ?: throw ResponseStatusException(
+ NOT_FOUND,
+ "Collection with id $uuid not ready or not found",
+ )
+ val sizedIterable =
+ auditLogRepository.findByTableAndId(
+ CollectionTable.tableName,
+ entityId = collection.id.value,
+ offsetPageable = pageable,
+ )
+
+ return ResponseEntity.status(200).body(sizedIterable.toList().map { it.toModel() })
+ }
+
+ @GetMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V3}${RoutePaths.WORKSPACE_PATH}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun getOrCreateWorkspace(
+ @AuthenticationPrincipal user: Jwt?,
+ ): ApiCollection? =
+ collectionRepository.findByOwner(oAuthHelper.readableUserIdentifier(user))?.let {
+ ApiCollection.fromDao(it, appConfig).also { coll ->
+ if (it.status in
+ listOf(
+ PublishStatus.Published,
+ PublishStatus.Archived,
+ )
+ ) {
+ coll.credentialEngineUrl =
+ credentialEngineUrlProvider.collectionFinderUrl(it.uuid.toString())
+ }
+ }
+ } ?: collectionRepository
+ .createFromApi(
+ listOf(
+ ApiCollectionUpdate(
+ name = DEFAULT_WORKSPACE_NAME,
+ publishStatus = PublishStatus.Workspace,
+ author = oAuthHelper.readableUserName(user),
+ skills = ApiStringListUpdate(),
+ ),
+ ),
+ richSkillRepository,
+ oAuthHelper.readableUserName(user),
+ oAuthHelper.readableUserIdentifier(user),
+ ).firstOrNull()
+ ?.let {
+ ApiCollection.fromDao(it, appConfig).also { coll ->
+ if (it.status in
+ listOf(
+ PublishStatus.Published,
+ PublishStatus.Archived,
+ )
+ ) {
+ coll.credentialEngineUrl =
+ credentialEngineUrlProvider.collectionFinderUrl(it.uuid.toString())
+ }
+ }
+ }
+
+ @GetMapping(
+ path = [
+ "${RoutePaths.API}${RoutePaths.API_V2}${RoutePaths.WORKSPACE_PATH}",
+ "${RoutePaths.API}${RoutePaths.UNVERSIONED}${RoutePaths.WORKSPACE_PATH}",
+ ],
+ produces = [MediaType.APPLICATION_JSON_VALUE],
+ )
+ @ResponseBody
+ fun getOrCreateWorkspaceV2(
+ @AuthenticationPrincipal user: Jwt?,
+ ): ApiCollection? = getOrCreateWorkspace(user)?.let { ApiCollectionV2.fromLatest(it, appConfig) }
}
-}
diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionDao.kt b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionDao.kt
index 975eadf91..f2a49b7ec 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionDao.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionDao.kt
@@ -9,7 +9,11 @@ import org.jetbrains.exposed.dao.LongEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import java.time.LocalDateTime
-class CollectionDao(id: EntityID) : LongEntity(id), OutputsModel, MutablePublishStatusDetails {
+class CollectionDao(
+ id: EntityID,
+) : LongEntity(id),
+ OutputsModel,
+ MutablePublishStatusDetails {
companion object : LongEntityClass(CollectionTable)
var creationDate by CollectionTable.creationDate
@@ -26,8 +30,8 @@ class CollectionDao(id: EntityID) : LongEntity(id), OutputsModel) : LongEntity(id), OutputsModel) : LongEntity(id), OutputsModel?,
-
@Field(type = Integer)
@Nullable
val skillCount: Int?,
-
@MultiField(
mainField = Field(type = Text, analyzer = "english_stemmer"),
otherFields = [
InnerField(suffix = "", type = Search_As_You_Type),
InnerField(suffix = "raw", analyzer = "whitespace_exact", type = Text),
- InnerField(suffix = "keyword", type = Keyword)
- ]
+ InnerField(suffix = "keyword", type = Keyword),
+ ],
)
@Nullable
@get:JsonIgnore
val author: String?,
-
@Field(type = FieldType.Date, format = [DateFormat.date_hour_minute_second])
@get:JsonProperty("archiveDate")
val archiveDate: LocalDateTime? = null,
-
@Field(type = FieldType.Date, format = [DateFormat.date_hour_minute_second])
@get:JsonProperty("publishDate")
val publishDate: LocalDateTime? = null,
-
@Field(type = Text)
@Nullable
- val workspaceOwner: String?
-
+ val workspaceOwner: String?,
)
diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionEsRepo.kt b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionEsRepo.kt
index 74156835d..8f0c2a528 100644
--- a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionEsRepo.kt
+++ b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionEsRepo.kt
@@ -6,11 +6,18 @@ import edu.wgu.osmt.config.INDEX_COLLECTION_DOC
import edu.wgu.osmt.db.PublishStatus
import edu.wgu.osmt.elasticsearch.FindsAllByPublishStatus
import edu.wgu.osmt.elasticsearch.OsmtQueryHelper.convertToNativeQuery
+import edu.wgu.osmt.elasticsearch.OsmtQueryHelper.createNativeQuery
import edu.wgu.osmt.elasticsearch.OsmtQueryHelper.createTermsDslQuery
import edu.wgu.osmt.richskill.RichSkillDoc
import edu.wgu.osmt.richskill.RichSkillEsRepo
import org.apache.lucene.search.join.ScoreMode
-import org.elasticsearch.index.query.*
+import org.elasticsearch.index.query.AbstractQueryBuilder
+import org.elasticsearch.index.query.BoolQueryBuilder
+import org.elasticsearch.index.query.InnerHitBuilder
+import org.elasticsearch.index.query.MultiMatchQueryBuilder
+import org.elasticsearch.index.query.NestedQueryBuilder
+import org.elasticsearch.index.query.Operator
+import org.elasticsearch.index.query.QueryBuilders
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
@@ -26,7 +33,6 @@ import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories
-
/**
* This have been partially converted to use the ElasticSearch 8.7.X apis. Need to do full conversion to use
* the v8.7.x ES Java API client, https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/8.10/searching.html
@@ -35,149 +41,271 @@ interface CustomCollectionQueries : FindsAllByPublishStatus {
val richSkillEsRepo: RichSkillEsRepo
fun collectionPropertiesMultiMatch(query: String): AbstractQueryBuilder<*>
+
fun byApiSearch(
apiSearch: ApiSearch,
publishStatus: Set = PublishStatus.publishStatusSet,
- pageable: Pageable = PageRequest.of(
- 0,
- PaginationDefaults.size,
- Sort.by("name.keyword").descending()
- )
+ pageable: Pageable =
+ PageRequest.of(
+ 0,
+ PaginationDefaults.size,
+ Sort.by("name.keyword").descending(),
+ ),
): SearchHits
+ fun countByApiSearch(
+ apiSearch: ApiSearch,
+ publishStatus: Set = PublishStatus.publishStatusSet,
+ pageable: Pageable =
+ PageRequest.of(
+ 0,
+ PaginationDefaults.size,
+ Sort.by("name.keyword").descending(),
+ ),
+ ): Long
+
fun deleteIndex() {
elasticSearchTemplate.indexOps(IndexCoordinates.of(INDEX_COLLECTION_DOC)).delete()
}
}
-class CustomCollectionQueriesImpl @Autowired constructor(
- override val elasticSearchTemplate: ElasticsearchTemplate,
- override val richSkillEsRepo: RichSkillEsRepo
-) :
- CustomCollectionQueries {
- val log: Logger = LoggerFactory.getLogger(CustomCollectionQueriesImpl::class.java)
- override val javaClass = CollectionDoc::class.java
-
- override fun collectionPropertiesMultiMatch(query: String): AbstractQueryBuilder<*> {
- val isComplex = query.contains("\"")
-
- val complexFields = arrayOf(
- "${CollectionDoc::name.name}.raw",
- "${CollectionDoc::name.name}._2gram",
- "${CollectionDoc::name.name}._3gram",
- "${CollectionDoc::description.name}.raw",
- CollectionDoc::author.name
- )
+class CustomCollectionQueriesImpl
+ @Autowired
+ constructor(
+ override val elasticSearchTemplate: ElasticsearchTemplate,
+ override val richSkillEsRepo: RichSkillEsRepo,
+ ) : CustomCollectionQueries {
+ val log: Logger = LoggerFactory.getLogger(CustomCollectionQueriesImpl::class.java)
+ override val javaClass = CollectionDoc::class.java
- val fields = arrayOf(
- CollectionDoc::name.name,
- "${CollectionDoc::name.name}._2gram",
- "${CollectionDoc::name.name}._3gram",
- CollectionDoc::description.name,
- CollectionDoc::author.name
- )
+ override fun collectionPropertiesMultiMatch(query: String): AbstractQueryBuilder<*> {
+ val isComplex = query.contains("\"")
+
+ val complexFields =
+ arrayOf(
+ "${CollectionDoc::name.name}.raw",
+ "${CollectionDoc::name.name}._2gram",
+ "${CollectionDoc::name.name}._3gram",
+ "${CollectionDoc::description.name}.raw",
+ CollectionDoc::author.name,
+ )
- return if (isComplex) {
- QueryBuilders.simpleQueryStringQuery(query).fields(complexFields.map { it to 1.0f }.toMap())
- .defaultOperator(Operator.AND)
- } else {
- QueryBuilders.multiMatchQuery(query, *fields).type(MultiMatchQueryBuilder.Type.BOOL_PREFIX)
+ val fields =
+ arrayOf(
+ CollectionDoc::name.name,
+ "${CollectionDoc::name.name}._2gram",
+ "${CollectionDoc::name.name}._3gram",
+ CollectionDoc::description.name,
+ CollectionDoc::author.name,
+ )
+
+ return if (isComplex) {
+ QueryBuilders
+ .simpleQueryStringQuery(query)
+ .fields(complexFields.map { it to 1.0f }.toMap())
+ .defaultOperator(Operator.AND)
+ } else {
+ QueryBuilders
+ .multiMatchQuery(
+ query,
+ *fields,
+ ).type(MultiMatchQueryBuilder.Type.BOOL_PREFIX)
+ }
}
- }
- /**
- * TODO upgrade to ElasticSearch v8.7.x api style; see KeywordEsRepo.kt & FindsAllByPublishStatus.kt
- */
- override fun byApiSearch(
- apiSearch: ApiSearch,
- publishStatus: Set,
- pageable: Pageable
- ): SearchHits {
- val nsqb1 = NativeSearchQueryBuilder().withPageable(Pageable.unpaged())
- val bq = QueryBuilders.boolQuery()
- val filterDslQuery = createTermsDslQuery(RichSkillDoc::publishStatus.name, publishStatus.map { ps -> ps.toString() })
- val filter = BoolQueryBuilder().must(
- QueryBuilders.termsQuery(
- RichSkillDoc::publishStatus.name,
- publishStatus.map { ps -> ps.toString() }
+ /**
+ * TODO upgrade to ElasticSearch v8.7.x api style; see KeywordEsRepo.kt & FindsAllByPublishStatus.kt
+ */
+ override fun byApiSearch(
+ apiSearch: ApiSearch,
+ publishStatus: Set,
+ pageable: Pageable,
+ ): SearchHits {
+ val filterDslQuery = publishStatusFilterDslQuery(publishStatus)
+ return getCollectionFromUuids(
+ pageable,
+ filterDslQuery,
+ matchingCollectionUuids(apiSearch, publishStatus, pageable),
+ "CustomCollectionQueriesImpl.byApiSearch()2",
+ log,
)
- )
- nsqb1.withFilter(filter)
- nsqb1.withQuery(bq)
-
- var collectionMultiPropertyResults: List = listOf()
-
- // treat the presence of query property to mean multi field search with that term
- if (!apiSearch.query.isNullOrBlank()) {
- // Search against rich skill properties
- bq.should(
- BoolQueryBuilder()
- .must(richSkillEsRepo.richSkillPropertiesMultiMatch(apiSearch.query))
- .must(createNestedQueryBuilder())
+ }
+
+ override fun countByApiSearch(
+ apiSearch: ApiSearch,
+ publishStatus: Set,
+ pageable: Pageable,
+ ): Long {
+ val filterDslQuery = publishStatusFilterDslQuery(publishStatus)
+ val uuids = matchingCollectionUuids(apiSearch, publishStatus, pageable)
+ if (uuids.isEmpty()) {
+ return 0L
+ }
+ val nativeQuery =
+ createNativeQuery(
+ Pageable.unpaged(),
+ filterDslQuery,
+ createTermsDslQuery("_id", uuids),
+ "CustomCollectionQueriesImpl.countByApiSearch()",
+ log,
+ )
+ return elasticSearchTemplate.count(nativeQuery, CollectionDoc::class.java)
+ }
+
+ private fun publishStatusFilterDslQuery(
+ publishStatus: Set,
+ ): co.elastic.clients.elasticsearch._types.query_dsl.Query =
+ createTermsDslQuery(
+ RichSkillDoc::publishStatus.name,
+ publishStatus.map { ps -> ps.toString() },
)
- bq.should(richSkillEsRepo.occupationQueries(apiSearch.query))
-
- val nsqb2 = NativeSearchQueryBuilder()
- .withQuery( collectionPropertiesMultiMatch(apiSearch.query) )
- .withPageable(Pageable.unpaged())
- .withFilter(filter)
- // search on collection specific properties
- val query = convertToNativeQuery(Pageable.unpaged(), filterDslQuery, nsqb2, "CustomCollectionQueriesImpl.byApiSearch()1", log)
- collectionMultiPropertyResults = elasticSearchTemplate
- .search(query, CollectionDoc::class.java)
- .searchHits
- .map { it.content.uuid }
-
- } else if (apiSearch.advanced != null) {
- richSkillEsRepo.generateBoolQueriesFromApiSearch(bq, apiSearch.advanced)
-
- if (!apiSearch.advanced.collectionName.isNullOrBlank()) {
- collectionMultiPropertyResults = getCollectionUuids(pageable, filterDslQuery, apiSearch.advanced.collectionName )
+
+ private fun matchingCollectionUuids(
+ apiSearch: ApiSearch,
+ publishStatus: Set,
+ pageable: Pageable,
+ ): List {
+ val nsqb1 = NativeSearchQueryBuilder().withPageable(Pageable.unpaged())
+ val bq = QueryBuilders.boolQuery()
+ val filterDslQuery = publishStatusFilterDslQuery(publishStatus)
+ val filter =
+ BoolQueryBuilder().must(
+ QueryBuilders.termsQuery(
+ RichSkillDoc::publishStatus.name,
+ publishStatus.map { ps -> ps.toString() },
+ ),
+ )
+ nsqb1.withFilter(filter)
+ nsqb1.withQuery(bq)
+
+ var collectionMultiPropertyResults: List = listOf()
+
+ if (!apiSearch.query.isNullOrBlank()) {
+ bq.should(
+ BoolQueryBuilder()
+ .must(richSkillEsRepo.richSkillPropertiesMultiMatch(apiSearch.query))
+ .must(createNestedQueryBuilder()),
+ )
+ bq.should(richSkillEsRepo.occupationQueries(apiSearch.query))
+
+ val nsqb2 =
+ NativeSearchQueryBuilder()
+ .withQuery(collectionPropertiesMultiMatch(apiSearch.query))
+ .withPageable(Pageable.unpaged())
+ .withFilter(filter)
+ val query =
+ convertToNativeQuery(
+ Pageable.unpaged(),
+ filterDslQuery,
+ nsqb2,
+ "CustomCollectionQueriesImpl.byApiSearch()1",
+ log,
+ )
+ collectionMultiPropertyResults =
+ elasticSearchTemplate
+ .search(query, CollectionDoc::class.java)
+ .searchHits
+ .map { it.content.uuid }
+ } else if (apiSearch.advanced != null) {
+ richSkillEsRepo.generateBoolQueriesFromApiSearch(bq, apiSearch.advanced)
+
+ if (!apiSearch.advanced.collectionName.isNullOrBlank()) {
+ collectionMultiPropertyResults =
+ getCollectionUuids(
+ pageable,
+ filterDslQuery,
+ apiSearch.advanced.collectionName,
+ )
+ } else {
+ bq.must(createNestedQueryBuilder())
+ }
} else {
bq.must(createNestedQueryBuilder())
}
- } else { // query nor advanced search was provided, return all collections
- bq.must(createNestedQueryBuilder())
- }
- var query = convertToNativeQuery(Pageable.unpaged(), filterDslQuery, nsqb1, "CustomCollectionQueriesImpl.byApiSearch().innerHitCollectionUuids", log)
- val innerHitCollectionUuids = elasticSearchTemplate
- .search(query, RichSkillDoc::class.java)
- .searchHits.mapNotNull { it.getInnerHits("collections")?.searchHits?.mapNotNull { it.content as CollectionDoc } }
- .flatten()
- .map { it.uuid }
- .distinct()
- return getCollectionFromUuids(pageable, filterDslQuery, (innerHitCollectionUuids + collectionMultiPropertyResults).distinct(), "CustomCollectionQueriesImpl.byApiSearch()2", log)
- }
+ val query =
+ convertToNativeQuery(
+ Pageable.unpaged(),
+ filterDslQuery,
+ nsqb1,
+ "CustomCollectionQueriesImpl.byApiSearch().innerHitCollectionUuids",
+ log,
+ )
+ val innerHitCollectionUuids =
+ elasticSearchTemplate
+ .search(query, RichSkillDoc::class.java)
+ .searchHits
+ .mapNotNull {
+ it
+ .getInnerHits(
+ "collections",
+ )?.searchHits
+ ?.mapNotNull { inner -> inner.content as CollectionDoc }
+ }.flatten()
+ .map { it.uuid }
+ .distinct()
+ return (
+ innerHitCollectionUuids +
+ collectionMultiPropertyResults
+ ).distinct()
+ }
- @Deprecated("Upgrade to ES v8.x queries", ReplaceWith("createNestQueryDslQuery"), DeprecationLevel.WARNING )
- private fun createNestedQueryBuilder(): NestedQueryBuilder {
- return QueryBuilders.nestedQuery(
+ @Deprecated(
+ "Upgrade to ES v8.x queries",
+ ReplaceWith("createNestQueryDslQuery"),
+ DeprecationLevel.WARNING,
+ )
+ private fun createNestedQueryBuilder(): NestedQueryBuilder =
+ QueryBuilders
+ .nestedQuery(
RichSkillDoc::collections.name,
QueryBuilders.matchAllQuery(),
- ScoreMode.Avg
+ ScoreMode.Avg,
).innerHit(InnerHitBuilder())
- }
- private fun getCollectionUuids(pageable: Pageable, filter: co.elastic.clients.elasticsearch._types.query_dsl.Query?, collectionName: String) : List {
- return if (collectionName.contains("\""))
- getCollectionUuidsFromComplexName(pageable, filter, collectionName, "getCollectionUuids", log)
- else
- getCollectionUuidsFromName(pageable, filter, collectionName, "getCollectionUuids", log)
+ private fun getCollectionUuids(
+ pageable: Pageable,
+ filter: co.elastic.clients.elasticsearch._types.query_dsl.Query?,
+ collectionName: String,
+ ): List =
+ if (collectionName.contains("\"")) {
+ getCollectionUuidsFromComplexName(
+ pageable,
+ filter,
+ collectionName,
+ "getCollectionUuids",
+ log,
+ )
+ } else {
+ getCollectionUuidsFromName(
+ pageable,
+ filter,
+ collectionName,
+ "getCollectionUuids",
+ log,
+ )
+ }
}
-}
@Configuration
@EnableElasticsearchRepositories("edu.wgu.osmt.collection")
class CollectionEsRepoConfig
-interface CollectionEsRepo : ElasticsearchRepository, CustomCollectionQueries {
- fun findByUuid(uuid: String, pageable: Pageable): Page
+interface CollectionEsRepo :
+ ElasticsearchRepository,
+ CustomCollectionQueries {
+ fun findByUuid(
+ uuid: String,
+ pageable: Pageable,
+ ): Page
fun findAllByUuidIn(
uuids: List,
- pageable: Pageable
+ pageable: Pageable,
): Page
- fun findByName(q: String, pageable: Pageable = PageRequest.of(0, PaginationDefaults.size)): Page
+ fun findByName(
+ q: String,
+ pageable: Pageable = PageRequest.of(0, PaginationDefaults.size),
+ ): Page