├── .apibuilder ├── .tracked_files └── config ├── .gitattributes ├── .gitignore ├── .java-version ├── .scalafmt.conf ├── DEVELOPER.md ├── Jenkinsfile ├── LICENSE ├── README.md ├── build.sbt ├── go.mod ├── go.sum ├── project ├── build.properties └── plugins.sbt ├── scripts └── diff.rb └── src ├── main └── scala │ └── io │ └── flow │ ├── build │ ├── Application.scala │ ├── BuildConfig.scala │ ├── BuildType.scala │ ├── Config.scala │ ├── Controller.scala │ ├── DownloadCache.scala │ ├── Downloader.scala │ ├── Environment.scala │ └── Main.scala │ ├── generated │ ├── ApicollectiveApibuilderApiV0Client.scala │ ├── ApicollectiveApibuilderCommonV0Models.scala │ ├── ApicollectiveApibuilderGeneratorV0Models.scala │ ├── FlowCommonV0Models.scala │ ├── FlowErrorV0Models.scala │ └── FlowRegistryV0Client.scala │ ├── lint │ ├── Controller.scala │ ├── Lint.scala │ ├── Linter.scala │ ├── linters │ │ ├── AllAttributesAreWellKnown.scala │ │ ├── BadNames.scala │ │ ├── BeaconEventsMustHaveAttributes.scala │ │ ├── CommonFieldTypes.scala │ │ ├── CommonParameterTypes.scala │ │ ├── CommonParametersHaveNoDescriptions.scala │ │ ├── DuplicateMethodAndPath.scala │ │ ├── ErrorModelsV1.scala │ │ ├── ErrorModelsV2.scala │ │ ├── EventHelpers.scala │ │ ├── EventStructure.scala │ │ ├── EventUpsertedModels.scala │ │ ├── ExpandableUnionsAreConsistent.scala │ │ ├── Get.scala │ │ ├── GetByIdIsExpandable.scala │ │ ├── Helpers.scala │ │ ├── InclusiveTerminologyLinter.scala │ │ ├── LowerCasePaths.scala │ │ ├── MappingModels.scala │ │ ├── MinimumMaximum.scala │ │ ├── ModelsWithOrganizationField.scala │ │ ├── PathsDoNotHaveTrailingSlash.scala │ │ ├── ProxyQueryParameters.scala │ │ ├── PublishedEventModels.scala │ │ ├── SortAttribute.scala │ │ ├── SortParameterDefault.scala │ │ ├── StandardResponse.scala │ │ ├── UnionTypesHaveCommonDiscriminator.scala │ │ ├── UpsertedDeletedEventModels.scala │ │ └── VersionModels.scala │ └── util │ │ └── Expansions.scala │ ├── oneapi │ ├── AllTypeNames.scala │ ├── Controller.scala │ ├── Defaults.scala │ ├── FlattenTypeNames.scala │ ├── Module.scala │ ├── OneApi.scala │ └── OperationSort.scala │ ├── proxy │ ├── ApiBuildAttributes.scala │ ├── Controller.scala │ ├── RegistryApplicationCache.scala │ ├── Route.scala │ ├── ServiceHostResolver.scala │ └── Text.scala │ └── stream │ ├── ApiBuilderUtils.scala │ ├── Controller.scala │ ├── EventType.scala │ ├── EventUnionTypeMatcher.scala │ └── KinesisStream.scala └── test └── scala └── io └── flow ├── ApplicationSpec.scala ├── helpers └── ServiceHostHelpers.scala ├── lint ├── AllAttributesAreWellKnownSpec.scala ├── BadNamesSpec.scala ├── BeaconEventsMustHaveAttributesSpec.scala ├── CommonFieldTypesSpec.scala ├── CommonParameterTypesSpec.scala ├── CommonParametersHaveNoDescriptionsSpec.scala ├── DuplicateMethodAndPathSpec.scala ├── ErrorModelsV1Spec.scala ├── ErrorModelsV2Spec.scala ├── ErrorUnionModelsSpec.scala ├── EventStructureSpec.scala ├── EventUpsertedModelsSpec.scala ├── ExpandableUnionsAreConsistentSpec.scala ├── GetByIdIsExpandableSpec.scala ├── GetQuerySpec.scala ├── GetWithExpansionsSpec.scala ├── GetWithoutExpansionsSpec.scala ├── HelpersSpec.scala ├── InclusiveTerminologyLinterSpec.scala ├── LintSpec.scala ├── LowerCasePathsSpec.scala ├── MappingModelsSpec.scala ├── MinimumMaximumSpec.scala ├── ModelsWithOrganizationFieldSpec.scala ├── PathsDoNotHaveTrailingSlashSpec.scala ├── ProxyQueryParametersSpec.scala ├── PublishedEventModelsSpec.scala ├── Services.scala ├── SortAttributeSpec.scala ├── SortParameterDefaultSpec.scala ├── StandardResponseSpec.scala ├── UnionTypesHaveCommonDiscriminatorSpec.scala ├── UpsertedDeletedEventModelsSpec.scala ├── VersionModelsSpec.scala └── util │ └── ExpansionsSpec.scala ├── oneapi └── OperationSortSpec.scala ├── proxy ├── ApiBuildAttributesSpec.scala ├── ServiceHostResolverSpec.scala └── TextSpec.scala └── stream └── EventUnionTypeMatcherSpec.scala /.apibuilder/.tracked_files: -------------------------------------------------------------------------------- 1 | --- 2 | apicollective: 3 | apibuilder-api: 4 | ning_1_9_client: 5 | - src/main/scala/io/flow/generated/ApicollectiveApibuilderApiV0Client.scala 6 | apibuilder-common: 7 | play_2_x_standalone_json: 8 | - src/main/scala/io/flow/generated/ApicollectiveApibuilderCommonV0Models.scala 9 | apibuilder-generator: 10 | play_2_x_standalone_json: 11 | - src/main/scala/io/flow/generated/ApicollectiveApibuilderGeneratorV0Models.scala 12 | flow: 13 | common: 14 | play_2_x_standalone_json: 15 | - src/main/scala/io/flow/generated/FlowCommonV0Models.scala 16 | error: 17 | play_2_x_standalone_json: 18 | - src/main/scala/io/flow/generated/FlowErrorV0Models.scala 19 | registry: 20 | ning_1_9_client: 21 | - src/main/scala/io/flow/generated/FlowRegistryV0Client.scala 22 | -------------------------------------------------------------------------------- /.apibuilder/config: -------------------------------------------------------------------------------- 1 | code: 2 | apicollective: 3 | apibuilder-common: 4 | version: latest 5 | generators: 6 | play_2_x_standalone_json: src/main/scala/io/flow/generated 7 | apibuilder-generator: 8 | version: latest 9 | generators: 10 | play_2_x_standalone_json: src/main/scala/io/flow/generated 11 | apibuilder-api: 12 | version: latest 13 | generators: 14 | ning_1_9_client: src/main/scala/io/flow/generated 15 | flow: 16 | common: 17 | version: latest 18 | generators: 19 | play_2_x_standalone_json: src/main/scala/io/flow/generated 20 | error: 21 | version: latest 22 | generators: 23 | play_2_x_standalone_json: src/main/scala/io/flow/generated 24 | registry: 25 | version: latest 26 | generators: 27 | ning_1_9_client: src/main/scala/io/flow/generated 28 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | **/generated/** linguist-generated 2 | .apibuilder/.tracked_files linguist-generated 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bsp/ 2 | .idea* 3 | *.iml 4 | target/ 5 | logs/ 6 | .ivy2 7 | *.swp 8 | *.swo 9 | .DS_Store 10 | release 11 | -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 17 -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.5.9 2 | runner.dialect=scala213 3 | maxColumn = 120 4 | continuationIndent.callSite = 2 5 | continuationIndent.defnSite = 2 6 | continuationIndent.ctorSite = 2 7 | continuationIndent.extendSite = 2 8 | align.preset = none 9 | rewrite.trailingCommas.style = always 10 | project.excludePaths = [ "glob:**/generated/**" ] 11 | -------------------------------------------------------------------------------- /DEVELOPER.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/flowcommerce/api-build.png?branch=main)](https://travis-ci.org/flowcommerce/api-build) 2 | 3 | # example commands 4 | 5 | sbt:api-build> run api oneapi flow/consumer-invoice flow/item flow/content flow/shopify-session flow/error flow/session flow/export flow/harmonization flow/fulfillment flow/ratecard flow/merchant-of-record flow/return flow/token flow/search flow/webhook flow/order-management flow/location flow/field-validation flow/common flow/marketing flow/currency flow/permission flow/checkout flow/import flow/sync flow/experience flow/payment-gateway flow/healthcheck flow/reference flow/jsonp flow/consumer-email flow/user flow/gift-card flow/catalog-exclusion flow/price flow/organization flow/payment flow/shopify flow/inventory flow/customer flow/tracking flow/ftp flow/fraud flow/catalog flow/query-builder flow/link flow/label flow/catalog-return flow/graphql flow/session-context flow/tax flow/order-price 6 | 7 | sbt:api-build> run api-event all flow/consumer-email-event flow/consumer-invoice-event flow/fulfillment-event flow/catalog-event flow/local-item-event flow/optin-event flow/price-event flow/payment-event flow/organization-event flow/experience-event flow/published-event flow/inventory-event flow/shopify-event flow/currency-event flow/label-event flow/harmonization-event flow/fraud-event flow/return-event flow/targeting-event flow/ratecard-event flow/tracking-event flow/order-management-event flow/customer-event 8 | 9 | sbt:api-build> run api-internal oneapi flow/alert-internal flow/demandware-internal flow/harmonization-engine-internal flow/feature flow/checkout-backend flow/screen flow/item-classification-api flow/inventory-internal flow/tax-internal flow/session-internal flow/blaze-heap flow/content-internal flow/bundle-checkout flow/address-configuration flow/shopify-internal flow/experience-internal flow/checkout-configuration flow/partner-internal flow/secret-internal flow/customer-internal flow/payment-internal flow/fulfillment-internal flow/bundle-browser flow/country-picker flow/return-internal flow/marketing-gateway flow/shopify-xborder-gateway-internal flow/ftp-internal flow/fraud-internal flow/experiment-internal flow/permission-internal flow/billing-internal flow/ratecard-internal flow/order-management-internal flow/checkout-http flow/checkout-analytics flow/optin-internal flow/currency-internal flow/order-messenger-internal flow/item-dimension-estimate flow/user-internal flow/organization-internal flow/issuer flow/label-internal flow/search-internal flow/export-internal flow/duty-internal flow/billing flow/invoice flow/catalog-internal flow/harmonization-internal flow/feature-task flow/magento-internal flow/checkout-protocol flow/blaze flow/labs-internal flow/tracking-internal flow/price-internal flow/experiment-engine-internal flow/checkout-common 10 | 11 | sbt:api-build> run api-internal-event all flow/tracking-internal-event flow/payment-internal-event flow/ratecard-internal-event flow/published-internal-event flow/customer-internal-event flow/user-internal-event flow/checkout-configuration-event flow/experiment-internal-event flow/order-management-internal-event flow/label-internal-event flow/experience-internal-event flow/catalog-internal-event flow/paypal-internal-event flow/marketing-gateway-internal-event flow/feature-event flow/ftp-event flow/item-dimension-estimate-event flow/harmonization-internal-event flow/shopify-internal-event flow/tax-internal-event flow/content-internal-event flow/currency-internal-event flow/localized-item-internal-event flow/pricing-indicator-internal-event flow/issuer-event flow/hybris-internal-event flow/optin-internal-event flow/import-internal-event flow/duty-internal-event flow/billing-event flow/svb-internal-event flow/fraud-internal-event flow/export-internal-event flow/fulfillment-internal-pregenerated-event flow/adyen-internal-event flow/stripe-internal-event flow/billing-internal-event 12 | 13 | sbt:api-build> run api-misc all flow/usage flow/api-mocker flow/websocket flow/beacon flow/checkout-backend-db flow/discount-request flow/beacon_redshift 14 | 15 | sbt:api-build> run api-misc-event all flow/beacon-event flow/beacon-clickstream-event 16 | 17 | sbt:api-build> run api-partner all flow/partner 18 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent { 3 | kubernetes { 4 | inheritFrom 'kaniko-slim' 5 | containerTemplates([ 6 | containerTemplate(name: 'play', image: 'flowdocker/play_builder:latest-java17-noble', command: 'cat', ttyEnabled: true), 7 | ]) 8 | } 9 | } 10 | 11 | options { 12 | disableConcurrentBuilds() 13 | } 14 | 15 | stages { 16 | stage('Checkout') { 17 | steps { 18 | checkoutWithTags scm 19 | } 20 | } 21 | 22 | stage('Tag new version') { 23 | when { branch 'main' } 24 | steps { 25 | script { 26 | VERSION = new flowSemver().calculateSemver() 27 | new flowSemver().commitSemver(VERSION) 28 | } 29 | } 30 | } 31 | 32 | stage('SBT Test') { 33 | steps { 34 | container('play') { 35 | script { 36 | try { 37 | sh ''' 38 | sbt clean coverage compile test scalafmtSbtCheck scalafmtCheck doc assembly 39 | sbt coverageAggregate 40 | ''' 41 | } finally { 42 | junit allowEmptyResults: true, testResults: '**/target/test-reports/*.xml' 43 | step([$class: 'ScoveragePublisher', reportDir: 'target/scala-2.13/scoverage-report', reportFile: 'scoverage.xml']) 44 | publishHTML (target : [allowMissing: false, 45 | alwaysLinkToLastBuild: true, 46 | keepAll: true, 47 | reportDir: 'target/scala-2.13/scoverage-report', 48 | reportFiles: 'index.html', 49 | reportName: 'Scoverage Code Coverage', 50 | reportTitles: 'Scoverage Code Coverage']) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | stage('SBT Publish') { 58 | when { branch 'main' } 59 | steps { 60 | container('play') { 61 | withAWS(roleAccount: '479720515435', role: 'flow-prod-eks-production-jenkins-role') { 62 | withCredentials([ 63 | usernamePassword( 64 | credentialsId: 'jenkins-x-github', 65 | usernameVariable: 'GIT_USERNAME', 66 | passwordVariable: 'GIT_PASSWORD' 67 | ) 68 | ]) { 69 | script { 70 | sh ''' 71 | git config --global credential.helper "store --file=/tmp/git-credentials" 72 | echo "https://$GIT_USERNAME:$GIT_PASSWORD@github.com" > /tmp/git-credentials 73 | git config --global --add safe.directory /home/jenkins/workspace 74 | git clone https://github.com/flowcommerce/aws-s3-public.git aws-s3-public 75 | ''' 76 | sh ''' 77 | cd aws-s3-public 78 | git checkout main && 79 | git pull --rebase && 80 | git fetch --tags origin 81 | ls 82 | pwd 83 | ''' 84 | sh ''' 85 | sbt scalafmtSbtCheck scalafmtCheck clean assembly 86 | cp ./target/scala-2.13/api-build-assembly-*.jar ./aws-s3-public/util/api-build/ 87 | cp ./target/scala-2.13/api-build-assembly-*.jar ./aws-s3-public/util/api-build/api-build.jar 88 | ''' 89 | sh ''' 90 | cd aws-s3-public 91 | git add util/api-build/* 92 | if git diff --cached --exit-code --quiet 93 | then 94 | echo 'Nothing to commit, not git-pushing nor s3-syncing.' >&2 95 | else 96 | git commit -m 'Add new version of api-build' util/api-build 97 | git push origin main 98 | aws s3 sync util s3://io.flow.aws-s3-public/util 99 | fi 100 | ''' 101 | syncDependencyLibrary() 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2020 Flow Commerce, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/flowcommerce/api-build.png?branch=main)](https://travis-ci.org/flowcommerce/api-build) 2 | 3 | # api-build 4 | 5 | Runs a set of tests against an API defined in apibuilder to ensure 6 | consistency, and then builds a few artifacts used in the end to end 7 | pipeline of API at gilt. 8 | 9 | Main features: 10 | 11 | - lint: Automated build to enforce standard conventions at Flow 12 | - oneapi: Merge multiple API specs into a single API, managed at https://app.apibuilder.io/flow/api 13 | - proxy: Generates configuration files for the API proxy, routing paths to services 14 | 15 | ## Installation 16 | 17 | curl https://s3.amazonaws.com/io.flow.aws-s3-public/util/api-build/api-build.jar > ~/api-build.jar 18 | 19 | ## Examples: 20 | 21 | ``` 22 | java -jar ~/api-build.jar api lint flow/common flow/user 23 | java -jar ~/api-build.jar api oneapi flow/common flow/user 24 | java -jar ~/api-build.jar api build flow/common flow/user 25 | ``` 26 | 27 | Or run the full build: 28 | 29 | ``` 30 | java -jar ~/api-build.jar api all flow/common flow/user 31 | ``` 32 | 33 | ## running locally 34 | 35 | api-build needs to access apibuilder and requires an API Token: 36 | 37 | 1. Goto https://app.apibuilder.io/tokens/ and create a token 38 | 39 | 2. Create the ~/.apibuilder/config file - see https://github.com/apicollective/apibuilder-cli 40 | 41 | 42 | ## building jar file 43 | 44 | We are using the sbt assembly plugin to build 45 | 46 | sbt assembly 47 | 48 | ## publishing jar file 49 | 50 | go run ./release.go 51 | 52 | ## Running from the command line: 53 | 54 | java -jar target/scala-2.13/api-build-assembly-xx.yy.zz.jar api lint flow/common flow/user 55 | java -jar target/scala-2.13/api-build-assembly-xx.yy.zz.jar api oneapi flow/common flow/user 56 | java -jar target/scala-2.13/api-build-assembly-xx.yy.zz.jar api all flow/common flow/user 57 | 58 | To specify a specific API Builder Profile: 59 | 60 | APIBUILDER_PROFILE=xxx java -jar target/scala-2.13/api-build-assembly-xx.yy.zz.jar api all flow/common flow/user 61 | 62 | Or to specify a specific APIBUILDER URL and/or Token: 63 | 64 | APIBUILDER_TOKEN=yyy APIBUILDER_API_BASE_URL=http://app.apibuilder.io java -jar /web/api-build/target/scala-2.11/api-build_2.11-0.0.1-one-jar.jar api all flow/user 65 | 66 | The default behavior is to use the default apibuilder profile. 67 | 68 | ## Releasing 69 | 70 | go run release.go 71 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "api-build" 2 | 3 | organization := "io.flow" 4 | 5 | ThisBuild / scalaVersion := "2.13.15" 6 | ThisBuild / javacOptions ++= Seq("-source", "17", "-target", "17") 7 | enablePlugins(GitVersioning) 8 | git.useGitDescribe := true 9 | coverageExcludedFiles := ".*\\/src/main/scala/io/flow/generated\\/.*" 10 | coverageDataDir := file("target/scala-2.13") 11 | coverageHighlighting := true 12 | coverageFailOnMinimum := true 13 | coverageMinimumStmtTotal := 48 14 | coverageMinimumBranchTotal := 52 15 | 16 | lazy val allScalacOptions = Seq( 17 | "-feature", 18 | "-Xfatal-warnings", 19 | "-unchecked", 20 | "-Xcheckinit", 21 | "-Xlint:adapted-args", 22 | "-Ypatmat-exhaust-depth", 23 | "100", // Fixes: Exhaustivity analysis reached max recursion depth, not all missing cases are reported. 24 | "-Wconf:src=generated/.*:silent", 25 | "-Wconf:src=target/.*:silent", // silence the unused imports errors generated by the Play Routes 26 | ) 27 | 28 | assembly / assemblyMergeStrategy := { 29 | case PathList("io", "flow", _*) => 30 | // we have multiple copies of apibuilder generated code 31 | // just take the first one, it's no worse than whatever happens in production 32 | MergeStrategy.first 33 | case "module-info.class" => 34 | MergeStrategy.discard 35 | case PathList("META-INF", "versions", xs @ _, "module-info.class") => 36 | MergeStrategy.discard 37 | case x => 38 | val oldStrategy = (assembly / assemblyMergeStrategy).value 39 | oldStrategy(x) 40 | } 41 | 42 | lazy val root = project 43 | .in(file(".")) 44 | .settings( 45 | scalafmtOnCompile := true, 46 | scalacOptions ++= allScalacOptions ++ Seq("-release", "17"), 47 | libraryDependencies ++= Seq( 48 | "io.flow" %% "lib-util" % "0.2.54", 49 | "io.apibuilder" %% "apibuilder-validation" % "0.4.33", 50 | "com.typesafe.play" %% "play-json" % "2.10.6", 51 | "com.ning" % "async-http-client" % "1.9.40", 52 | "org.typelevel" %% "cats-core" % "2.10.0", 53 | "org.typelevel" %% "cats-effect" % "2.3.3", 54 | "org.scalatest" %% "scalatest" % "3.2.18" % Test, 55 | "com.github.scopt" %% "scopt" % "4.1.0", 56 | ), 57 | ) 58 | 59 | resolvers += "Artifactory" at "https://flow.jfrog.io/flow/libs-release/" 60 | Test / javaOptions ++= Seq( 61 | "--add-exports=java.base/sun.security.x509=ALL-UNNAMED", 62 | "--add-opens=java.base/sun.security.ssl=ALL-UNNAMED", 63 | ) 64 | credentials += Credentials( 65 | "Artifactory Realm", 66 | "flow.jfrog.io", 67 | System.getenv("ARTIFACTORY_USERNAME"), 68 | System.getenv("ARTIFACTORY_PASSWORD"), 69 | ) 70 | 71 | publishTo := { 72 | val host = "https://flow.jfrog.io/flow" 73 | if (isSnapshot.value) { 74 | Some("Artifactory Realm" at s"$host/libs-snapshot-local;build.timestamp=" + new java.util.Date().getTime) 75 | } else { 76 | Some("Artifactory Realm" at s"$host/libs-release-local") 77 | } 78 | } 79 | version := "0.3.24" 80 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flowcommerce/api-build 2 | 3 | go 1.18 4 | 5 | require github.com/flowcommerce/tools v0.0.0-20220627140943-7d835a0f1edb 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/flowcommerce/tools v0.0.0-20220627140943-7d835a0f1edb h1:H6kkzY45wRcCS+1rpx6FqGqw9joJhdhV0mf/ZjEDjwg= 2 | github.com/flowcommerce/tools v0.0.0-20220627140943-7d835a0f1edb/go.mod h1:f6dOYUeW/yWym6XHNpGB1LZSjI8nmZ4cQ0OZ3XmBPL0= 3 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.10.1 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.0") 2 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.1") 3 | 4 | addSbtPlugin("com.github.sbt" % "sbt-git" % "2.1.0") 5 | 6 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") 7 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.2.2") 8 | -------------------------------------------------------------------------------- /scripts/diff.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | def readFiles(dir) 4 | Dir.glob("#{dir}/*").map { |f| File.basename(f) } 5 | end 6 | 7 | def diff(file) 8 | a = File.join("main/#{file}") 9 | b = File.join("explore/#{file}") 10 | if File.exists?(a) && File.exists?(b) 11 | output = "#{file}.diff.txt" 12 | `diff #{a} #{b} > #{output}` 13 | if File.read(output).to_s.strip.empty? 14 | File.delete(output) 15 | "Identical" 16 | else 17 | "Has differences" 18 | end 19 | else 20 | nil 21 | end 22 | end 23 | 24 | files = (readFiles("./explore") + readFiles("./main")).uniq 25 | 26 | all = {} 27 | files.each do |f| 28 | if changes = diff(f) 29 | all[changes] ||= [] 30 | all[changes] << f 31 | end 32 | end 33 | 34 | all.keys.sort.each do |key| 35 | puts "" 36 | puts key 37 | all[key].sort.each do |d| 38 | puts " - #{d}" 39 | end 40 | end 41 | 42 | puts "" 43 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/build/Application.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | case class Application( 4 | organization: String, 5 | application: String, 6 | version: String, 7 | ) { 8 | val isLatest: Boolean = version == Application.Latest 9 | 10 | val applicationVersionLabel: String = if (isLatest) { 11 | s"$application:latest" 12 | } else { 13 | s"$application:$version" 14 | } 15 | 16 | val label: String = s"$organization/$applicationVersionLabel" 17 | } 18 | 19 | object Application { 20 | 21 | val Latest = "latest" 22 | 23 | def latest(organization: String, application: String): Application = { 24 | Application( 25 | organization = organization, 26 | application = application, 27 | version = Latest, 28 | ) 29 | } 30 | 31 | def parse(value: String): Option[Application] = { 32 | value.split("/").map(_.trim).toList match { 33 | case org :: app :: Nil => { 34 | app.split(":").map(_.trim).toList match { 35 | case Nil => { 36 | None 37 | } 38 | 39 | case name :: Nil => { 40 | Some(Application(org, name, Latest)) 41 | } 42 | 43 | case name :: version :: Nil => { 44 | Some(Application(org, name, version)) 45 | } 46 | 47 | case _ => { 48 | None 49 | } 50 | } 51 | } 52 | 53 | case _ => { 54 | None 55 | } 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/build/BuildConfig.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | /** Additional configuration passed to the run method of each Controller. 4 | * 5 | * @param protocol 6 | * Used by the proxy controller when constructing the value of each host in the servers list. 7 | * @param domain 8 | * Used by the proxy controller when constructing the value of each host in the servers list. 9 | * @param output 10 | * Where controllers write files created. 11 | */ 12 | case class BuildConfig(protocol: String, domain: String, output: java.nio.file.Path) 13 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/build/BuildType.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | sealed trait BuildType { 4 | def oneApi: Boolean 5 | def proxy: Boolean 6 | def key: String 7 | def name: String 8 | def namespace: String 9 | def isEvent: Boolean 10 | 11 | // added for backwards compatibility as early versions of api-build relied on 12 | // toString being the key 13 | final override def toString: String = key 14 | } 15 | 16 | sealed trait BuildEventType extends BuildType 17 | 18 | object BuildType { 19 | 20 | case object Api extends BuildType { 21 | override def oneApi: Boolean = true 22 | override def proxy: Boolean = true 23 | override def key = "api" 24 | override def name = "API" 25 | override def namespace = "io.flow" 26 | override def isEvent: Boolean = false 27 | } 28 | case object ApiEvent extends BuildType { 29 | override def oneApi: Boolean = true 30 | override def proxy: Boolean = true 31 | override def key = "api-event" 32 | override def name = "API Event" 33 | override def namespace = "io.flow.event" 34 | override def isEvent: Boolean = true 35 | } 36 | case object ApiInternal extends BuildType { 37 | override def oneApi: Boolean = true 38 | override def proxy: Boolean = true 39 | override def key = "api-internal" 40 | override def name = "API Internal" 41 | override def namespace = "io.flow.internal" 42 | override def isEvent: Boolean = false 43 | } 44 | case object ApiInternalEvent extends BuildType { 45 | override def oneApi: Boolean = true 46 | override def proxy: Boolean = true 47 | override def key = "api-internal-event" 48 | override def name = "API Internal Event" 49 | override def namespace = "io.flow.internal.event" 50 | override def isEvent: Boolean = true 51 | } 52 | case object ApiMisc extends BuildType { 53 | override def oneApi: Boolean = false 54 | override def proxy: Boolean = false 55 | override def key = "api-misc" 56 | override def name = "API Misc" 57 | override def namespace = "io.flow.misc" 58 | override def isEvent: Boolean = false 59 | } 60 | case object ApiMiscEvent extends BuildType { 61 | override def oneApi: Boolean = false 62 | override def proxy: Boolean = false 63 | override def key = "api-misc-event" 64 | override def name = "API Misc Event" 65 | override def namespace = "io.flow.misc.event" 66 | override def isEvent: Boolean = true 67 | } 68 | case object ApiPartner extends BuildType { 69 | override def oneApi: Boolean = true 70 | override def proxy: Boolean = true 71 | override def key = "api-partner" 72 | override def name = "API Partner" 73 | override def namespace = "io.flow.partner" 74 | override def isEvent: Boolean = false 75 | } 76 | 77 | val all = Seq(Api, ApiEvent, ApiInternal, ApiInternalEvent, ApiMisc, ApiMiscEvent, ApiPartner) 78 | 79 | private[this] val byName = all.map(x => x.toString.toLowerCase -> x).toMap 80 | 81 | def fromString(value: String): Option[BuildType] = byName.get(value.toLowerCase) 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/build/Config.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | case class Config( 4 | buildType: BuildType = BuildType.Api, 5 | protocol: String = "https", 6 | domain: String = "api.flow.io", 7 | buildCommand: String = "all", 8 | apis: Seq[String] = Seq(), 9 | output: java.nio.file.Path = java.nio.file.Paths.get("/tmp"), 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/build/Controller.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | 5 | trait Controller { 6 | 7 | private[this] val internalErrors = scala.collection.mutable.Map[String, Seq[String]]() 8 | private[this] val GlobalError = "Global" 9 | 10 | protected[this] def addError(message: String): Unit = { 11 | addError(GlobalError, message) 12 | } 13 | 14 | protected[this] def addError(key: String, error: String): Unit = { 15 | internalErrors.get(key) match { 16 | case None => { 17 | internalErrors.put(key, Seq(error)) 18 | } 19 | case Some(existing) => { 20 | internalErrors.put(key, existing ++ Seq(error)) 21 | } 22 | } 23 | () 24 | } 25 | 26 | def name: String 27 | 28 | def command: String 29 | 30 | /** Run things and return a list of errors 31 | */ 32 | def run( 33 | buildType: BuildType, 34 | buildConfig: BuildConfig, 35 | downloadCache: DownloadCache, 36 | services: Seq[Service], 37 | )(implicit 38 | ec: scala.concurrent.ExecutionContext, 39 | ): Unit 40 | 41 | def errors(): Map[String, Seq[String]] = internalErrors.toMap 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/build/DownloadCache.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | import io.apibuilder.spec.v0.models.{Import, Service} 4 | 5 | import scala.annotation.tailrec 6 | import scala.collection.concurrent.TrieMap 7 | 8 | case class DownloadCache(downloader: Downloader)(implicit 9 | ec: scala.concurrent.ExecutionContext, 10 | ) { 11 | 12 | private[this] val cache = TrieMap[Application, Service]() 13 | 14 | private[this] def cacheKey(a: Application) = Application.latest(a.organization, a.application) 15 | private[this] def isDefinedAt(application: Application): Boolean = cache.isDefinedAt(cacheKey(application)) 16 | private[this] def toApplication(imp: Import): Application = 17 | Application.latest(imp.organization.key, imp.application.key) 18 | 19 | @tailrec 20 | final def downloadAllServicesAndImports(services: Seq[Service], index: Int = 0): Seq[Service] = { 21 | val all = services ++ mustDownloadServices( 22 | services.flatMap(_.imports).map(toApplication), 23 | ) 24 | val missing = all.flatMap(_.imports).map(toApplication).filterNot(isDefinedAt) 25 | if (missing.isEmpty) { 26 | all 27 | } else { 28 | downloadAllServicesAndImports(all, index + 1) 29 | } 30 | } 31 | 32 | def mustDownloadServices(applications: Seq[Application]): Seq[Service] = { 33 | downloadServices(applications) match { 34 | case Left(errors) => sys.error(s"Failed to download services: ${errors.mkString(", ")}") 35 | case Right(services) => services 36 | } 37 | } 38 | 39 | def downloadServices( 40 | applications: Seq[Application], 41 | ): Either[Seq[String], Seq[Service]] = { 42 | 43 | val (cached, remaining) = applications.partition { a => isDefinedAt(cacheKey(a)) } 44 | 45 | downloader.downloadServices(remaining.distinct).map { rest => 46 | rest.foreach { s => 47 | cache.put(Application.latest(s.organization.key, s.application.key), s) 48 | } 49 | cached.map { a => 50 | cache.getOrElse(cacheKey(a), sys.error("Invalid cache entry for application")) 51 | } ++ rest 52 | } 53 | } 54 | 55 | def downloadService(app: Application): Either[Seq[String], Service] = { 56 | downloadServices(Seq(app)).map(_.head) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/build/Downloader.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | import cats.data.Validated.{Invalid, Valid} 4 | import cats.data.ValidatedNec 5 | import cats.implicits._ 6 | import com.ning.http.client.{AsyncHttpClient, AsyncHttpClientConfig} 7 | import io.apibuilder.api.v0.models.{BatchDownloadApplicationForm, BatchDownloadApplicationsForm} 8 | import io.apibuilder.spec.v0.models.Service 9 | 10 | import scala.annotation.tailrec 11 | import scala.concurrent.Await 12 | import scala.concurrent.duration.{FiniteDuration, SECONDS} 13 | import scala.util.{Failure, Success, Try} 14 | 15 | /** Utility to download service.json files from API Builder 16 | */ 17 | private[build] case class Downloader(config: ApibuilderProfile) { 18 | 19 | def downloadServices( 20 | applications: Seq[Application], 21 | )(implicit 22 | ec: scala.concurrent.ExecutionContext, 23 | ): Either[Seq[String], Seq[Service]] = { 24 | downloadServices( 25 | orgKeys = applications.map(_.organization).distinct, 26 | applications = applications, 27 | ) 28 | } 29 | 30 | @tailrec 31 | private[this] final def downloadServices( 32 | orgKeys: Seq[String], 33 | applications: Seq[Application], 34 | errors: Seq[String] = Nil, 35 | services: Seq[Service] = Nil, 36 | )(implicit 37 | ec: scala.concurrent.ExecutionContext, 38 | ): Either[Seq[String], Seq[Service]] = { 39 | orgKeys.toList match { 40 | case Nil => 41 | if (errors.isEmpty) { 42 | Right(services) 43 | } else { 44 | Left(errors) 45 | } 46 | case one :: rest => { 47 | downloadBatch(one, applications.filter(_.organization == one)) match { 48 | case Invalid(newErrors) => downloadServices(rest, applications, errors ++ newErrors.toList, services) 49 | case Valid(newServices) => downloadServices(rest, applications, errors, services ++ newServices) 50 | } 51 | } 52 | } 53 | } 54 | 55 | private[this] def withClient[T](f: io.apibuilder.api.v0.Client => T): T = { 56 | val client = new io.apibuilder.api.v0.Client( 57 | baseUrl = config.baseUrl, 58 | auth = config.token.map { token => 59 | io.apibuilder.api.v0.Authorization.Basic(token) 60 | }, 61 | asyncHttpClient = new AsyncHttpClient( 62 | new AsyncHttpClientConfig.Builder() 63 | .setExecutorService(java.util.concurrent.Executors.newCachedThreadPool()) 64 | .build(), 65 | ), 66 | ) 67 | try { 68 | f(client) 69 | } finally { 70 | client.closeAsyncHttpClient() 71 | } 72 | } 73 | 74 | private[this] def downloadBatch(orgKey: String, applications: Seq[Application])(implicit 75 | ec: scala.concurrent.ExecutionContext, 76 | ): ValidatedNec[String, Seq[Service]] = { 77 | assert( 78 | applications.forall(_.organization == orgKey), 79 | "All applications must belong to the same org for batch download", 80 | ) 81 | 82 | println( 83 | s"Downloading API Builder Service Spec for $orgKey: " + 84 | applications.map(_.applicationVersionLabel).sorted.mkString(" "), 85 | ) 86 | Try { 87 | withClient { client => 88 | Await.result( 89 | client.batchDownloadApplications.post( 90 | orgKey = orgKey, 91 | batchDownloadApplicationsForm = BatchDownloadApplicationsForm( 92 | applications = applications.map { a => 93 | BatchDownloadApplicationForm( 94 | applicationKey = a.application, 95 | version = a.version, 96 | ) 97 | }, 98 | ), 99 | ), 100 | FiniteDuration(120, SECONDS), 101 | ) 102 | } 103 | } match { 104 | case Success(result) => result.applications.map(_.service).validNec 105 | case Failure(ex) => { 106 | ex match { 107 | case io.apibuilder.api.v0.errors.UnitResponse(401) => { 108 | s"HTTP 401: you are not authorized to download the services for org $orgKey".invalidNec 109 | } 110 | case io.apibuilder.api.v0.errors.UnitResponse(404) => { 111 | s"HTTP 404: services for org $orgKey not found (or you might not be authorized)".invalidNec 112 | } 113 | case _ => { 114 | ex.printStackTrace() 115 | s"Error downloading service for org $orgKey: ${ex.getMessage}".invalidNec 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/build/Environment.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | import scala.util.{Failure, Success, Try} 4 | 5 | case class ApibuilderProfile(name: String, baseUrl: String, token: Option[String] = None) 6 | 7 | /** Parses the API Builder configuration file 8 | */ 9 | object ApibuilderConfig { 10 | 11 | private[this] val DefaultPath = "~/.apibuilder/config" 12 | 13 | private[this] val DefaultApibuilderProfile = ApibuilderProfile( 14 | name = "default", 15 | baseUrl = "https://api.apibuilder.io", 16 | token = None, 17 | ) 18 | 19 | /** Loads API Builder configuration from the API Builder configuration file, returning either an error or the 20 | * configuration. You can set the APIBUILDER_PROFILE environment variable if you want to parse a specific profile. 21 | * 22 | * @param path 23 | * The path to the configuration file we are reading 24 | */ 25 | def load( 26 | path: String = DefaultPath, 27 | ): Either[String, ApibuilderProfile] = { 28 | val profileName = Environment.optionalString("APIBUILDER_PROFILE").getOrElse(DefaultApibuilderProfile.name) 29 | val envToken = Environment.optionalString("APIBUILDER_TOKEN") 30 | val envBaseUrl = Environment.optionalString("APIBUILDER_API_BASE_URL") 31 | 32 | val profileOrErrors: Either[String, ApibuilderProfile] = loadAllProfiles(path) match { 33 | case Left(errors) => { 34 | Left(errors) 35 | } 36 | 37 | case Right(profiles) => { 38 | profiles.find(_.name == profileName) match { 39 | case None => { 40 | if (profileName == DefaultApibuilderProfile.name) { 41 | Right(DefaultApibuilderProfile) 42 | } else { 43 | Left(s"API Builder profile named[$profileName] not found") 44 | } 45 | } 46 | case Some(p) => { 47 | Right(p) 48 | } 49 | } 50 | } 51 | } 52 | 53 | profileOrErrors match { 54 | case Left(errors) => Left(errors) 55 | case Right(profile) => { 56 | val p2 = envToken match { 57 | case None => profile 58 | case Some(token) => { 59 | println("Using API Builder token from environment variable") 60 | profile.copy(token = Some(token)) 61 | } 62 | } 63 | 64 | val p3 = envBaseUrl match { 65 | case None => p2 66 | case Some(url) => { 67 | println(s"Using API Builder baseUrl[$url] from environment variable") 68 | profile.copy(baseUrl = url) 69 | } 70 | } 71 | 72 | Right(p3) 73 | } 74 | } 75 | } 76 | 77 | private[this] val Profile = """\[profile (.+)\]""".r 78 | private[this] val Default = """\[default\]""".r 79 | 80 | private[this] def loadAllProfiles(path: String): Either[String, Seq[ApibuilderProfile]] = { 81 | val fullPath = path.replaceFirst("^~", System.getProperty("user.home")) 82 | val allProfiles = scala.collection.mutable.ListBuffer[ApibuilderProfile]() 83 | 84 | Try( 85 | if (new java.io.File(fullPath).exists) { 86 | var currentProfile: Option[ApibuilderProfile] = None 87 | 88 | scala.io.Source.fromFile(fullPath).getLines().map(_.trim).foreach { 89 | case Profile(name) => { 90 | currentProfile.map { p => allProfiles += p } 91 | currentProfile = Some(ApibuilderProfile(name = name, baseUrl = DefaultApibuilderProfile.baseUrl)) 92 | } 93 | case Default() => { 94 | currentProfile.map { p => allProfiles += p } 95 | currentProfile = Some(DefaultApibuilderProfile) 96 | } 97 | case l => { 98 | l.split("=").map(_.trim).toList match { 99 | case "token" :: value :: Nil => { 100 | currentProfile = currentProfile.map(_.copy(token = Some(value))) 101 | } 102 | case "api_uri" :: value :: Nil => { 103 | currentProfile = currentProfile.map(_.copy(baseUrl = value)) 104 | } 105 | case _ => { 106 | // ignore 107 | } 108 | } 109 | } 110 | } 111 | 112 | currentProfile.map { p => allProfiles += p } 113 | }, 114 | ) match { 115 | case Success(_) => { 116 | Right(allProfiles.toSeq) 117 | } 118 | case Failure(ex) => Left(ex.toString) 119 | } 120 | } 121 | 122 | } 123 | 124 | /** Helper to read configuration from environment 125 | */ 126 | object Environment { 127 | 128 | def optionalString(name: String): Option[String] = { 129 | sys.env.get(name).map(_.trim).map { value => 130 | value match { 131 | case "" => { 132 | sys.error(s"Value for environment variable[$name] cannot be blank") 133 | } 134 | case _ => value 135 | } 136 | } 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/Controller.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | import io.flow.build.{BuildConfig, BuildType, DownloadCache} 5 | 6 | case class Controller() extends io.flow.build.Controller { 7 | 8 | override val name = "Linter" 9 | override val command = "lint" 10 | 11 | def run( 12 | buildType: BuildType, 13 | buildConfig: BuildConfig, 14 | downloadCache: DownloadCache, 15 | services: Seq[Service], 16 | )(implicit 17 | ec: scala.concurrent.ExecutionContext, 18 | ): Unit = { 19 | services.foreach { service => 20 | print(s"${service.name}...") 21 | 22 | Lint(buildType).validate(service) match { 23 | case Nil => println(" Valid!") 24 | case errors => { 25 | errors.size match { 26 | case 1 => println(" 1 error:") 27 | case n => println(s" $n errors:") 28 | } 29 | errors.sorted.foreach { error => 30 | addError(service.name, error) 31 | println(s" - $error") 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/Lint.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | import io.apibuilder.spec.v0.models.json._ 5 | import io.flow.build.BuildType 6 | import play.api.libs.json.Json 7 | 8 | case class Lint( 9 | buildType: BuildType, 10 | ) { 11 | 12 | def validate(service: Service): Seq[String] = { 13 | Lint.forBuildType(buildType).flatMap(_.validate(service)) 14 | } 15 | 16 | } 17 | 18 | object Lint { 19 | 20 | def forBuildType(buildType: BuildType): Seq[Linter] = { 21 | buildType match { 22 | case BuildType.ApiMisc | BuildType.ApiMiscEvent => 23 | Seq( 24 | linters.BeaconEventsMustHaveAttributes, 25 | ) 26 | 27 | case _ => { 28 | Seq( 29 | linters.AllAttributesAreWellKnown, 30 | linters.BadNames, 31 | linters.CommonFieldTypes, 32 | linters.CommonParameterTypes, 33 | linters.CommonParametersHaveNoDescriptions, 34 | linters.DuplicateMethodAndPath, 35 | linters.ErrorModelsV1, 36 | linters.ErrorModelsV2, 37 | linters.EventStructure, 38 | linters.EventUpsertedModels, 39 | linters.ExpandableUnionsAreConsistent, 40 | linters.Get, 41 | linters.GetByIdIsExpandable, 42 | linters.InclusiveTerminologyLinter, 43 | linters.LowerCasePaths, 44 | linters.MappingModels, 45 | linters.MinimumMaximum, 46 | linters.ModelsWithOrganizationField, 47 | linters.PathsDoNotHaveTrailingSlash, 48 | linters.ProxyQueryParameters, 49 | linters.PublishedEventModels, 50 | linters.SortAttribute, 51 | linters.SortParameterDefault, 52 | linters.StandardResponse, 53 | linters.UnionTypesHaveCommonDiscriminator, 54 | linters.UpsertedDeletedEventModels, 55 | linters.VersionModels, 56 | ) 57 | } 58 | } 59 | } 60 | 61 | def fromFile(buildType: BuildType, path: String): Seq[String] = { 62 | val source = scala.io.Source.fromFile(path) 63 | try { 64 | val contents = source.getLines().mkString("\n") 65 | val service = Json.parse(contents).as[Service] 66 | Lint(buildType).validate(service) 67 | } finally { 68 | source.close() 69 | } 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/Linter.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | 5 | trait Linter { 6 | 7 | /** Validates that this service, returning a list of errors. Returning an empty list indicates the service is valid. 8 | */ 9 | def validate(service: Service): Seq[String] 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/AllAttributesAreWellKnown.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import io.flow.lint.Linter 5 | 6 | /** Ensures that attributes we no longer support are not specified 7 | */ 8 | case object AllAttributesAreWellKnown extends Linter { 9 | 10 | private[this] val KnownAttributeNames = Set( 11 | "api-build", 12 | "graphql", 13 | "linter", 14 | "non-crud", 15 | "sort", 16 | "io.flow.proxy", 17 | ) 18 | 19 | override def validate(service: Service): Seq[String] = { 20 | allAttributes(service).flatMap(validateAttribute).distinct 21 | } 22 | 23 | private[this] def validateAttribute(attr: Attribute): Seq[String] = { 24 | if (KnownAttributeNames.contains(attr.name)) { 25 | Nil 26 | } else { 27 | Seq(errorMessage(attr.name)) 28 | } 29 | } 30 | 31 | private[this] def errorMessage(attributeName: String): String = { 32 | s"Service contains an unknown attribute named '$attributeName' - remove this attribute or add to AllAttributesAreWellKnown.KnownAttributeNames in the api-build project (https://github.com/flowcommerce/api-build)" 33 | } 34 | 35 | private[this] def allAttributes(service: Service): Seq[Attribute] = { 36 | service.headers.flatMap(_.attributes) ++ 37 | service.enums.flatMap(_.attributes) ++ 38 | service.interfaces.flatMap(_.attributes) ++ 39 | service.unions.flatMap(_.attributes) ++ 40 | service.models.flatMap(_.attributes) ++ 41 | service.resources.flatMap(_.attributes) ++ 42 | service.resources.flatMap(_.operations).flatMap(_.attributes) ++ 43 | service.resources.flatMap(_.operations).flatMap(_.parameters).flatMap(_.attributes.getOrElse(Nil)) ++ 44 | service.resources.flatMap(_.operations).flatMap(_.responses).flatMap(_.attributes.getOrElse(Nil)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/BadNames.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Attribute, Field, Model, Operation, Parameter, Resource, Service} 5 | 6 | /** We have decided to call the same things consistently. This linter validates common field and parameter names 7 | */ 8 | case object BadNames extends Linter with Helpers { 9 | 10 | private[this] val Data: Map[String, String] = Map( 11 | "ip_address" -> "ip", 12 | "postal_code" -> "postal", 13 | ) 14 | 15 | override def validate(service: Service): Seq[String] = { 16 | service.models 17 | .filterNot(m => ignoreFilter(m.attributes)) 18 | .flatMap(validateModel) ++ 19 | service.resources.flatMap(validateResource) 20 | } 21 | 22 | def validateModel(model: Model): Seq[String] = { 23 | model.fields.flatMap { validateField(model, _) } 24 | } 25 | 26 | def validateField(model: Model, field: Field): Seq[String] = { 27 | Data.get(field.name) match { 28 | case None => Nil 29 | case Some(replacement) => Seq(error(model, field, s"Name must be '$replacement'")) 30 | } 31 | } 32 | 33 | def validateResource(resource: Resource): Seq[String] = { 34 | resource.operations 35 | .filterNot(o => ignoreFilter(o.attributes)) 36 | .flatMap(validateOperation(resource, _)) 37 | } 38 | 39 | def validateOperation(resource: Resource, operation: Operation): Seq[String] = { 40 | operation.parameters.flatMap(validateParameter(resource, operation, _)) 41 | } 42 | 43 | def validateParameter(resource: Resource, operation: Operation, param: Parameter): Seq[String] = { 44 | Data.get(param.name) match { 45 | case None => Nil 46 | case Some(replacement) => Seq(error(resource, operation, param, s"Name must be '$replacement'")) 47 | } 48 | } 49 | 50 | def ignoreFilter(attributes: Seq[Attribute]): Boolean = { 51 | ignored(attributes, "bad_names") 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/BeaconEventsMustHaveAttributes.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import io.flow.lint.Linter 5 | 6 | /** All subtypes of the beacon event must have an attributes field 7 | */ 8 | case object BeaconEventsMustHaveAttributes extends Linter with Helpers { 9 | 10 | private[this] val AttributesType = "beacon_attributes" 11 | 12 | override def validate(service: Service): Seq[String] = { 13 | service.unions.find(_.name == "event") match { 14 | case None => Nil 15 | case Some(u) => { 16 | val typeNames = u.types.map(_.`type`).toSet 17 | service.models.filter { m => typeNames.contains(m.name) }.flatMap(validateModel) 18 | } 19 | } 20 | } 21 | 22 | def validateModel(model: Model): Seq[String] = { 23 | model.fields.find(_.name == "attributes") match { 24 | case None => { 25 | Seq(error(model, s"Must have a field named 'attributes' of type '$AttributesType'")) 26 | } 27 | case Some(f) => { 28 | validateField(model, f) 29 | } 30 | } 31 | } 32 | 33 | def validateField(model: Model, field: Field): Seq[String] = { 34 | val requiredErrors = if (field.required) { 35 | Seq("not be required") 36 | } else { 37 | Nil 38 | } 39 | 40 | val typeErrors = if (field.`type` == AttributesType) { 41 | Nil 42 | } else { 43 | Seq(s"have type '$AttributesType' and not '${field.`type`}'") 44 | } 45 | 46 | (requiredErrors ++ typeErrors).toList match { 47 | case Nil => Nil 48 | case errors => { 49 | Seq(error(model, field, "Must " + errors.mkString(" and "))) 50 | } 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/CommonFieldTypes.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Field, Model, Service} 5 | 6 | /** For well known field names, enforce specific types to ensure consistency. For example, all fields named 'id' must be 7 | * strings. 8 | */ 9 | case object CommonFieldTypes extends Linter with Helpers { 10 | 11 | private[this] val Expected: Map[String, String] = Map( 12 | "id" -> "string", // we use string identifiers for all of our resources 13 | "number" -> "string", // 'number' is the external unique identifier 14 | "guid" -> "uuid", 15 | "email" -> "string", 16 | ) 17 | 18 | override def validate(service: Service): Seq[String] = { 19 | service.models.filter(m => !ignored(m.attributes, "common_field_types")).flatMap(validateModel) 20 | } 21 | 22 | def validateModel(model: Model): Seq[String] = { 23 | model.fields.flatMap(validateFieldType(model, _)) 24 | } 25 | 26 | def validateFieldType(model: Model, field: Field): Seq[String] = { 27 | Expected.get(field.name) match { 28 | case None => { 29 | Nil 30 | } 31 | case Some(exp) => { 32 | if (exp == field.`type`) { 33 | Nil 34 | } else { 35 | Seq(error(model, field, s"Type must be '$exp' and not ${field.`type`}")) 36 | } 37 | } 38 | } 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/CommonParameterTypes.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Method, Operation, Parameter, Resource, Service} 5 | 6 | /** For well known parameters, enforce specific types, defaults, minimums and maximums. Validates only GET operations. 7 | */ 8 | case object CommonParameterTypes extends Linter with Helpers { 9 | 10 | case class Spec( 11 | typ: String, 12 | default: Option[String] = None, 13 | minimum: Option[Long] = None, 14 | maximum: Option[Long] = None, 15 | ) 16 | 17 | private[this] val Expected: Map[String, Spec] = Map( 18 | "id" -> Spec("[string]", default = None, minimum = None, maximum = Some(100)), 19 | "limit" -> Spec("long", default = Some("25"), minimum = Some(1), maximum = Some(100)), 20 | "offset" -> Spec("long", default = Some("0"), minimum = Some(0), maximum = None), 21 | ) 22 | 23 | private[this] val Types: Map[String, String] = Map( 24 | "sort" -> "string", 25 | "expand" -> "[string]", 26 | ) 27 | 28 | override def validate(service: Service): Seq[String] = { 29 | nonHealthcheckResources(service).flatMap(validateResource) 30 | } 31 | 32 | def validateResource(resource: Resource): Seq[String] = { 33 | resource.operations 34 | .filter(_.method == Method.Get) 35 | .filter(returnsArray) 36 | .filter(op => !ignored(op.attributes, "common_parameter_types")) 37 | .flatMap { op => 38 | op.parameters.flatMap { param => 39 | validateParameter(resource, op, param) 40 | } 41 | } 42 | } 43 | 44 | def validateParameter(resource: Resource, op: Operation, param: Parameter): Seq[String] = { 45 | Expected.get(param.name) match { 46 | case None => { 47 | Types.get(param.name) match { 48 | case None => Nil 49 | case Some(typ) => { 50 | if (typ == param.`type`) { 51 | Nil 52 | } else { 53 | Seq(error(resource, op, param, s"Type expected[$typ] but found[${param.`type`}]")) 54 | } 55 | } 56 | } 57 | } 58 | 59 | case Some(spec) => { 60 | compare(resource, op, param, "Type", Some(param.`type`), Some(spec.typ)) ++ 61 | compare(resource, op, param, "Default", param.default.map(_.toString), spec.default.map(_.toString)) ++ 62 | compare(resource, op, param, "Minimum", param.minimum, spec.minimum) ++ 63 | compare(resource, op, param, "Maximum", param.maximum, spec.maximum) 64 | } 65 | } 66 | } 67 | 68 | private[this] def compare[T]( 69 | resource: Resource, 70 | op: Operation, 71 | param: Parameter, 72 | label: String, 73 | actual: Option[T], 74 | expected: Option[T], 75 | ): Seq[String] = { 76 | (actual, expected) match { 77 | case (None, None) => Nil 78 | case (None, Some(e)) => Seq(error(resource, op, param, s"$label was not specified - should be $e")) 79 | case (Some(_), None) => Seq(error(resource, op, param, s"$label should not be specified")) 80 | case (Some(a), Some(e)) => { 81 | if (a == e) { 82 | Nil 83 | } else { 84 | if (param.name == "id" && op.path.endsWith("/versions") && param.`type` == "[long]") { 85 | // Special case as versions use journal id which is a long 86 | Nil 87 | } else { 88 | Seq(error(resource, op, param, s"$label expected[$e] but found[$a]")) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/CommonParametersHaveNoDescriptions.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Operation, Parameter, Resource, Service} 5 | 6 | /** parameters named: 7 | * 8 | * id, limit, offset, sort, expand 9 | * 10 | * should not have descriptions. This enables us to generate consistent documenetation without worrying about whether a 11 | * particular description adds anything useful. 12 | */ 13 | case object CommonParametersHaveNoDescriptions extends Linter with Helpers { 14 | 15 | val NamesWithNoDescriptions: Seq[String] = Seq("id", "limit", "offset", "sort", "expand") 16 | 17 | override def validate(service: Service): Seq[String] = { 18 | service.resources.flatMap(validateResource) 19 | } 20 | 21 | def validateResource(resource: Resource): Seq[String] = { 22 | resource.operations 23 | .filter(op => !ignored(op.attributes, "common_parameters_have_no_description")) 24 | .flatMap(validateOperation(resource, _)) 25 | } 26 | 27 | def validateOperation(resource: Resource, operation: Operation): Seq[String] = { 28 | operation.parameters.flatMap(validateParameterDescription(resource, operation, _)) 29 | } 30 | 31 | def validateParameterDescription(resource: Resource, operation: Operation, parameter: Parameter): Seq[String] = { 32 | parameter.description match { 33 | case None => { 34 | Nil 35 | } 36 | case Some(_) => { 37 | if (NamesWithNoDescriptions.contains(parameter.name)) { 38 | Seq(error(resource, operation, parameter, "Must not have a description")) 39 | } else { 40 | Nil 41 | } 42 | } 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/DuplicateMethodAndPath.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models.{Operation, Service} 4 | import io.flow.lint.Linter 5 | 6 | /** Validates that we do not have duplicate method and paths 7 | */ 8 | case object DuplicateMethodAndPath extends Linter with Helpers { 9 | 10 | override def validate(service: Service): Seq[String] = { 11 | service.resources.flatMap(_.operations).groupBy(toKey).filter(_._2.length > 1).keys.toList.sorted match { 12 | case Nil => Nil 13 | case dups => Seq(s"1 or more operation paths is duplicated: ${dups.mkString(", ")}") 14 | } 15 | } 16 | 17 | private[this] def toKey(operation: Operation): String = { 18 | s"${operation.method} ${operation.path}" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/ErrorModelsV1.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Field, Model, Service, Union} 5 | 6 | /** For error models (those with an error_id field in position 1), validate: 7 | * 8 | * a. second field is timestamp b. if 'organization', next c. if 'number', next 9 | */ 10 | case object ErrorModelsV1 extends Linter with Helpers { 11 | 12 | override def validate(service: Service): Seq[String] = { 13 | val unionsThatEndInError = 14 | service.unions.filter { u => !ignored(u.attributes, "error") }.filter { u => isError(u.name) } 15 | 16 | val modelErrors = service.models 17 | .filter { m => isError(m.name) } 18 | .filter { m => !ignored(m.attributes, "error") } 19 | .filter { m => 20 | !unions(service, m).exists { u => isError(u.name) } 21 | } 22 | .filter { m => errorVersion(m.attributes).contains(1) } 23 | .flatMap(validateModel(service, _)) 24 | 25 | val unionErrors = unionsThatEndInError.flatMap(validateUnion(service, _)) 26 | 27 | modelErrors ++ unionErrors 28 | } 29 | 30 | private[this] def validateUnion(service: Service, union: Union): Seq[String] = { 31 | val discriminatorFields: Seq[Field] = union.discriminator.map { discName => 32 | Field( 33 | name = discName, 34 | `type` = "string", 35 | required = true, 36 | ) 37 | }.toSeq 38 | 39 | union.types.flatMap { t => 40 | service.models.find(_.name == t.`type`) match { 41 | case None => { 42 | Seq(error(union, t, "Type must refer to a model to be part of an 'error' union type")) 43 | } 44 | 45 | case Some(m) => { 46 | val nameErrors = if (isError(m.name)) { 47 | Nil 48 | } else { 49 | Seq(error(union, t, "Model name must end with '_error'")) 50 | } 51 | 52 | val modelErrors = validateModel( 53 | service, 54 | m.copy( 55 | fields = discriminatorFields ++ m.fields, 56 | ), 57 | ) 58 | 59 | nameErrors ++ modelErrors 60 | } 61 | } 62 | } 63 | 64 | } 65 | 66 | private[this] def validateModel(service: Service, model: Model): Seq[String] = { 67 | val fieldNames = model.fields.map(_.name).toList 68 | fieldNames match { 69 | case "code" :: "messages" :: _ => { 70 | val codeErrors = if (model.fields.head.`type` == "string") { 71 | Nil 72 | } else if (hasEnum(service, model.fields.head.`type`)) { 73 | Nil 74 | } else { 75 | Seq(error(model, model.fields.head, s"type[${model.fields.head.`type`}] must refer to a valid enum")) 76 | } 77 | 78 | val messagesErrors = validateMessageField(model, model.fields(1)) 79 | 80 | codeErrors ++ messagesErrors 81 | } 82 | 83 | case _ => { 84 | val codeErrors = if (fieldNames.contains("code")) { 85 | fieldNames match { 86 | case "code" :: _ => Nil 87 | case _ => Seq(error(model, "first field must be 'code'")) 88 | } 89 | } else { 90 | Seq(error(model, "requires a field named 'code'")) 91 | } 92 | 93 | val messagesErrors = if (fieldNames.contains("messages")) { 94 | fieldNames match { 95 | case _ :: "messages" :: _ => Nil 96 | case _ => { 97 | if (fieldNames.contains("code")) { 98 | Seq(error(model, "second field must be 'messages'")) 99 | } else { 100 | Nil 101 | } 102 | } 103 | } 104 | } else { 105 | Seq(error(model, "requires a field named 'messages'")) 106 | } 107 | 108 | codeErrors ++ messagesErrors 109 | } 110 | } 111 | } 112 | 113 | def validateMessageField(model: Model, field: Field): Seq[String] = { 114 | val typeErrors = if (field.`type` == "[string]") { 115 | Nil 116 | } else { 117 | Seq(error(model, field, "type must be '[string]'")) 118 | } 119 | 120 | val minimumErrors = field.minimum match { 121 | case Some(n) => { 122 | if (n >= 1) { 123 | Nil 124 | } else { 125 | Seq(error(model, field, "minimum must be >= 1")) 126 | } 127 | } 128 | case None => { 129 | Seq(error(model, field, "missing minimum")) 130 | } 131 | } 132 | 133 | typeErrors ++ minimumErrors 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/ErrorModelsV2.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models.{Field, Model, Service} 4 | import io.flow.lint.Linter 5 | 6 | /** Models ending with _errors, validate: 7 | * a. contains a field named errors that is an array b. the type of the array is a model ending in _error 8 | * 9 | * Models ending with _error, validate: 10 | * a. contains a field named code whose type is an enum b. contains a field name message whose type is a string 11 | */ 12 | case object ErrorModelsV2 extends Linter with Helpers { 13 | 14 | override def validate(service: Service): Seq[String] = { 15 | service.models 16 | .filter { m => !ignored(m.attributes, "error") } 17 | .flatMap { m => 18 | if (m.name.endsWith("_errors")) { 19 | validateWrapper(m) 20 | } else if (isErrorModel(m)) { 21 | validateModel(service, m) 22 | } else { 23 | Nil 24 | } 25 | } 26 | } 27 | 28 | private[this] def isErrorModel(m: Model): Boolean = { 29 | m.name.endsWith("_error") && !errorVersion(m.attributes).contains(1) && m.fields.exists(_.name == "code") 30 | } 31 | private[this] def validateWrapper(model: Model): Seq[String] = { 32 | model.fields.find(_.name == "errors") match { 33 | case None => Seq(error(model, "must contain a field named 'errors'")) 34 | case Some(f) => validateWrapperType(model, f) 35 | } 36 | } 37 | 38 | private[this] def validateWrapperType(model: Model, field: Field): Seq[String] = { 39 | if (isArray(field.`type`)) { 40 | val fieldType = baseType(field.`type`) 41 | if (fieldType.endsWith("_error")) { 42 | Nil 43 | } else { 44 | // TODO: Verify type is a model 45 | Seq(error(model, field, s"type '${field.`type`}' must end in '_error'")) 46 | } 47 | } else { 48 | Seq(error(model, field, s"type must be an array and not '${field.`type`}'")) 49 | } 50 | } 51 | 52 | private[this] def validateModel(service: Service, model: Model): Seq[String] = { 53 | validateModelCode(service, model) ++ validateModelMessage(model) 54 | } 55 | 56 | private[this] def validateModelCode(service: Service, model: Model): Seq[String] = { 57 | model.fields.find(_.name == "code") match { 58 | case None => Seq(error(model, "must contain a field named 'code'")) 59 | case Some(f) => { 60 | service.enums.find(_.name == f.`type`) match { 61 | case None => Seq(error(model, f, "type must resolve to a known enum")) 62 | case Some(_) => Nil 63 | } 64 | } 65 | } 66 | } 67 | 68 | private[this] def validateModelMessage(model: Model): Seq[String] = { 69 | model.fields.find(_.name == "message") match { 70 | case None => Seq(error(model, "must contain a field named 'message'")) 71 | case Some(f) => { 72 | if (f.`type` == "string") { 73 | Nil 74 | } else { 75 | Seq(error(model, f, s"type must be 'string' and not '${f.`type`}'")) 76 | } 77 | } 78 | } 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/EventHelpers.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models.{Model, Service, Union} 4 | 5 | object EventModel { 6 | def fromModel(model: Model): Option[EventModel] = { 7 | val i = model.name.indexOf("_upserted") 8 | if (i > 0) { 9 | Some(UpsertedEventModel(model, model.name.substring(0, i))) 10 | } else { 11 | val i = model.name.indexOf("_deleted") 12 | if (i > 0) { 13 | Some(DeletedEventModel(model, model.name.substring(0, i))) 14 | } else { 15 | None 16 | } 17 | } 18 | } 19 | } 20 | 21 | sealed trait EventModel { 22 | def model: Model 23 | // eg. for user_upserted -> 'user' 24 | def prefix: String 25 | } 26 | case class UpsertedEventModel(model: Model, prefix: String) extends EventModel 27 | case class DeletedEventModel(model: Model, prefix: String) extends EventModel 28 | 29 | case class EventInstance( 30 | union: Union, 31 | models: Seq[EventModel], 32 | ) { 33 | val upserted: Seq[UpsertedEventModel] = models.collect { case m: UpsertedEventModel => m } 34 | val deleted: Seq[DeletedEventModel] = models.collect { case m: DeletedEventModel => m } 35 | } 36 | 37 | trait EventHelpers extends Helpers { 38 | 39 | def findAllEvents(service: Service): Seq[EventInstance] = { 40 | service.unions.filter(isEvent).map { union => 41 | EventInstance( 42 | union = union, 43 | models = union.types.flatMap { t => 44 | EventModel.fromModel( 45 | service.models.find(_.name == t.`type`).getOrElse { 46 | sys.error(s"Union '${union.name}': Failed to find model named ${t.`type`}") 47 | }, 48 | ) 49 | }, 50 | ) 51 | } 52 | } 53 | 54 | private[this] def isEvent(union: Union): Boolean = union.name.endsWith("_event") 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/EventUpsertedModels.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Model, Service} 5 | 6 | /** For event models (models ending with 'upserted', 'deleted'), validate: 7 | * 8 | * a. second field is timestamp b. if 'organization', next c. if 'number', next 9 | */ 10 | case object EventUpsertedModels extends Linter with Helpers { 11 | 12 | override def validate(service: Service): Seq[String] = { 13 | service.models.filter(m => !ignored(m.attributes, "event_model")).filter(isEvent).flatMap(validateOrganizationModel) 14 | } 15 | 16 | private[this] val Suffixes = List( 17 | "upserted", 18 | "deleted", 19 | ) 20 | 21 | private[this] def isEvent(model: Model): Boolean = { 22 | Suffixes.exists { s => model.name.endsWith(s"_$s") } 23 | } 24 | 25 | private[this] def validateOrganizationModel(model: Model): Seq[String] = { 26 | validateFieldNames(model) ++ validateFieldTypes( 27 | model, 28 | Map( 29 | "event_id" -> "string", 30 | "timestamp" -> "date-time-iso8601", 31 | "organization" -> "string", 32 | "number" -> "string", 33 | ), 34 | ) 35 | } 36 | 37 | private[this] def validateFieldNames(model: Model): Seq[String] = { 38 | val fieldNames = model.fields.map(_.name).toList 39 | fieldNames match { 40 | case "event_id" :: "timestamp" :: "organization" :: "number" :: _ => Nil 41 | 42 | case "event_id" :: "timestamp" :: "organization" :: _ => { 43 | if (fieldNames.contains("number")) { 44 | Seq(error(model, "number field must come after organization in event models")) 45 | } else { 46 | Nil 47 | } 48 | } 49 | 50 | case "event_id" :: "timestamp" :: "id" :: "organization" :: "number" :: _ => Nil 51 | 52 | case "event_id" :: "timestamp" :: "id" :: "organization" :: _ => { 53 | if (fieldNames.contains("number")) { 54 | Seq(error(model, "number field must come after organization in event models")) 55 | } else { 56 | Nil 57 | } 58 | } 59 | 60 | case "event_id" :: "timestamp" :: "id" :: rest => { 61 | validateOrgAndNumber(model, rest, "id") 62 | } 63 | 64 | case "event_id" :: "timestamp" :: rest => { 65 | validateOrgAndNumber(model, rest, "timestamp") 66 | } 67 | 68 | case _ => { 69 | val eventIdErrors = if (fieldNames.headOption.contains("event_id")) { 70 | Nil 71 | } else { 72 | Seq(error(model, "event_id must be the first field in event models")) 73 | } 74 | 75 | val timestampErrors = if (fieldNames.contains("timestamp")) { 76 | error(model, "timestamp field must come after event_id in event models") 77 | } else { 78 | error(model, "timestamp field is required in event models") 79 | } 80 | 81 | eventIdErrors ++ Seq(timestampErrors) ++ validateOrgAndNumber(model, fieldNames, "timestamp") 82 | } 83 | } 84 | } 85 | 86 | private[this] def validateOrgAndNumber(model: Model, fieldNames: Seq[String], priorFieldName: String): Seq[String] = { 87 | if (fieldNames.contains("organization")) { 88 | Seq(error(model, s"organization field must come after $priorFieldName in event models")) 89 | } else if (fieldNames.contains("number")) { 90 | Seq(error(model, "organization field is required if event model has a field named number")) 91 | } else { 92 | Nil 93 | } 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/ExpandableUnionsAreConsistent.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Service, Union} 5 | 6 | /** Validates that we are consistent with our expansion union types, e.g.: 7 | * 8 | * "expandable_organization": { "discriminator": "discriminator", "types": [ { "type": "organization" }, { "type": 9 | * "organization_reference" } ] } 10 | * 11 | * We validate that any union named expandable_xxx has exactly two types: xxx and xxx_reference. Note that the reason 12 | * we force the reference last is that when we use parser combinators, we look in order for the first matching type. 13 | * Having the types in this order means the organization will match before the reference (otherwise we would always end 14 | * up matching on the reference). 15 | */ 16 | case object ExpandableUnionsAreConsistent extends Linter with Helpers { 17 | 18 | private[this] val Pattern = """^expandable_(.+)$""".r 19 | 20 | override def validate(service: Service): Seq[String] = { 21 | service.unions.flatMap { u => validateUnion(service, u) } 22 | } 23 | 24 | def validateUnion(service: Service, union: Union): Seq[String] = { 25 | union.name match { 26 | case Pattern(name) => { 27 | service.unions.find(_.name == name) match { 28 | case None => validateUnionTypes(union, Seq(name, s"${name}_reference")) ++ validateTypeOrder(union, name) 29 | case Some(u) => 30 | validateUnionTypes(union, u.types.map(_.`type`) ++ Seq(s"${name}_reference")) ++ validateTypeOrder( 31 | union, 32 | name, 33 | ) 34 | } 35 | } 36 | case _ => { 37 | Nil 38 | } 39 | } 40 | } 41 | 42 | def validateUnionTypes(union: Union, types: Seq[String]): Seq[String] = { 43 | val names = union.types.map(_.`type`) 44 | types.filterNot(names.contains).toList match { 45 | case Nil => { 46 | Nil 47 | } 48 | case missing :: Nil => { 49 | Seq(error(union, s"must contain a type named '$missing'")) 50 | } 51 | case missing => { 52 | Seq(error(union, s"must contain the following types: " + missing.mkString(", "))) 53 | } 54 | } 55 | } 56 | 57 | def validateTypeOrder(union: Union, name: String): Seq[String] = { 58 | val names = union.types.map(_.`type`) 59 | val refName = s"${name}_reference" 60 | if (names.contains(name) && names.contains(refName)) { 61 | val defined = Seq(name, refName) 62 | val expected = defined ++ names.filterNot(defined.contains) 63 | if (expected == names) { 64 | Nil 65 | } else { 66 | Seq(error(union, s"types must be in the following order: " + expected.mkString(", "))) 67 | } 68 | } else { 69 | Nil 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/GetByIdIsExpandable.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.flow.lint.util.Expansions 5 | import io.apibuilder.spec.v0.models.{Method, Operation, Resource, Service} 6 | 7 | /** Enforce that for models with expansion where the return type is expandable, the get/:id method has the expand 8 | * parameter 9 | */ 10 | case object GetByIdIsExpandable extends Linter with Helpers { 11 | 12 | override def validate(service: Service): Seq[String] = { 13 | nonHealthcheckResources(service).flatMap { resource => 14 | resource.operations.filter(_.method == Method.Get).filter(returnsExpandableType).flatMap { 15 | validateOperationHasExpandParameter(resource, _) 16 | } 17 | } 18 | } 19 | 20 | def validateOperationHasExpandParameter(resource: Resource, op: Operation): Seq[String] = { 21 | op.parameters.find(_.name == "expand") match { 22 | case None => { 23 | Seq(error(resource, op, "Missing parameter named expand")) 24 | } 25 | case Some(_) => { 26 | Nil 27 | } 28 | } 29 | } 30 | 31 | def returnsExpandableType(op: Operation): Boolean = { 32 | responseType(op) match { 33 | case None => false 34 | case Some(t) => { 35 | Expansions.fromFieldTypes(Seq(t)) match { 36 | case Nil => false 37 | case _ => true 38 | } 39 | } 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/InclusiveTerminologyLinter.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import io.flow.lint.Linter 5 | 6 | /** With diversity in mind and in an effort to foster an inclusive and safe environment, this linter ensures we avoid 7 | * the usage of words that can be offensive. 8 | * 9 | * @see 10 | * [RFC](https://docs.google.com/document/d/1V33mFQETX_XalLcGjE7m9kP_LzCwHslHlYlCGi3RqSc/edit) 11 | */ 12 | case object InclusiveTerminologyLinter extends Linter with Helpers { 13 | 14 | private[this] val Suggestions = Map( 15 | "whitelist" -> "allowlist", 16 | "blacklist" -> "denylist", 17 | "master" -> "primary or leader", 18 | "slave" -> "secondary or follower", 19 | "dummy" -> "placeholder", 20 | "sanity" -> "completeness", 21 | "young" -> "junior", 22 | "old" -> "senior", 23 | ) 24 | // Note leaving 'gender' out of linter as capturing gender is a valid use case 25 | 26 | override def validate(service: Service): Seq[String] = { 27 | service.models.flatMap(validateModel) ++ 28 | service.enums.flatMap(validateEnum) ++ 29 | service.unions.flatMap(validateUnion) ++ 30 | service.interfaces.flatMap(validateInterface) ++ 31 | service.headers.flatMap(validateHeader) ++ 32 | service.resources.flatMap(validateResource) 33 | } 34 | 35 | def validateInterface(interface: Interface): Seq[String] = { 36 | validateName(interface.name) { m => error(interface, m) } ++ interface.fields.flatMap { 37 | validateField(interface, _) 38 | } 39 | } 40 | 41 | def validateField(interface: Interface, field: Field): Seq[String] = { 42 | validateName(field.name) { m => error(interface, field, m) } 43 | } 44 | 45 | def validateHeader(header: Header): Seq[String] = { 46 | validateName(header.name) { m => error(header, m) } 47 | } 48 | 49 | def validateEnum(`enum`: Enum): Seq[String] = { 50 | validateName(enum.name) { m => error(enum, m) } ++ enum.values.flatMap { validateEnumValue(enum, _) } 51 | } 52 | 53 | def validateEnumValue(`enum`: Enum, enumValue: EnumValue): Seq[String] = { 54 | validateName(enumValue.name) { m => error(enum, enumValue, m) } ++ (enumValue.value match { 55 | case None => Nil 56 | case Some(v) => validateName(v) { m => error(enum, enumValue, s"value: $m") } 57 | }) 58 | } 59 | 60 | def validateModel(model: Model): Seq[String] = { 61 | validateName(model.name) { m => error(model, m) } ++ model.fields.flatMap { validateField(model, _) } 62 | } 63 | 64 | def validateField(model: Model, field: Field): Seq[String] = { 65 | validateName(field.name) { m => error(model, field, m) } 66 | } 67 | 68 | def validateUnion(union: Union): Seq[String] = { 69 | validateName(union.name) { m => error(union, m) } ++ validateDiscriminator(union) ++ union.types.flatMap { 70 | validateUnionType(union, _) 71 | } 72 | } 73 | 74 | def validateDiscriminator(union: Union): Seq[String] = { 75 | union.discriminator match { 76 | case None => Nil 77 | case Some(disc) => validateName(disc) { m => error(union, s"discriminator: $m") } 78 | } 79 | } 80 | 81 | def validateUnionType(union: Union, unionType: UnionType): Seq[String] = { 82 | validateName(unionType.`type`) { m => error(union, unionType, m) } ++ validateUnionTypeDiscriminatorValue( 83 | union, 84 | unionType, 85 | ) 86 | } 87 | 88 | def validateUnionTypeDiscriminatorValue(union: Union, unionType: UnionType): Seq[String] = { 89 | unionType.discriminatorValue match { 90 | case None => Nil 91 | case Some(dv) => validateName(dv) { m => error(union, unionType, s"discriminator value: $m") } 92 | } 93 | } 94 | 95 | def validateResource(resource: Resource): Seq[String] = { 96 | resource.operations.flatMap(validateOperation(resource, _)) 97 | } 98 | 99 | def validateOperation(resource: Resource, operation: Operation): Seq[String] = { 100 | operation.parameters.flatMap(validateParameter(resource, operation, _)) 101 | } 102 | 103 | def validateParameter(resource: Resource, operation: Operation, param: Parameter): Seq[String] = { 104 | validateName(param.name) { m => error(resource, operation, param, m) } 105 | } 106 | 107 | def validateName(name: String)(errorF: String => String): Seq[String] = { 108 | Suggestions.get(name) match { 109 | case None => Nil 110 | case Some(s) => Seq(errorF(s"The term '$name' must be replaced by '$s'")) 111 | } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/LowerCasePaths.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | import io.flow.lint.Linter 5 | 6 | /** We keep all paths in lower case to avoid any issues with case sensitivity. 7 | */ 8 | case object LowerCasePaths extends Linter with Helpers { 9 | 10 | override def validate(service: Service): Seq[String] = { 11 | service.resources.flatMap { resource => 12 | resource.operations.filter(op => op.path != op.path.toLowerCase).map { op => 13 | error(resource, op, "Path must be all lower case") 14 | } 15 | } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/MappingModels.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models.{Field, Model, Service} 4 | import io.flow.lint.Linter 5 | 6 | /** Mapping models create qualified associations between two models. We enforce naming such that 7 | * __mapping must have the fields: 8 | * 9 | * a. id b. model1 of type model1_reference c. model2 of type model2_reference 10 | */ 11 | case object MappingModels extends Linter with Helpers { 12 | 13 | override def validate(service: Service): Seq[String] = { 14 | service.models.filter { m => isMapping(m.name) }.flatMap(validateModel) 15 | } 16 | 17 | private[this] def validateModel(model: Model): Seq[String] = { 18 | model.fields.toList match { 19 | case f1 :: f2 :: f3 :: _ => { 20 | val typeErrors = validateTypes(f1, f2, f3) 21 | 22 | val nameErrors = typeErrors.toList match { 23 | case Nil => validateNames(f1, f2, f3) 24 | case _ => Nil 25 | } 26 | 27 | val modelNameErrors = (typeErrors ++ nameErrors).toList match { 28 | case Nil => validateModelName(s"${f2.name}_${f3.name}_mapping", model.name) 29 | case _ => Nil 30 | } 31 | 32 | typeErrors ++ nameErrors ++ modelNameErrors 33 | } 34 | case _ => { 35 | Seq( 36 | error(model, "Mapping models must have at least 3 fields"), 37 | ) 38 | } 39 | } 40 | } 41 | 42 | private[this] def validateTypes(f1: Field, f2: Field, f3: Field): Seq[String] = { 43 | val f1Errors = if (f1.`type` == "string") { 44 | Nil 45 | } else { 46 | Seq(s"Field '${f1.name}' type must be 'string'") 47 | } 48 | 49 | val f2Errors = if (isReference(f2.`type`)) { 50 | Nil 51 | } else { 52 | Seq(s"Field '${f2.name}' type must be '${f2.name}_reference'") 53 | } 54 | 55 | val f3Errors = if (isReference(f3.`type`)) { 56 | Nil 57 | } else { 58 | Seq(s"Field '${f3.name}' type must be '${f3.name}_reference'") 59 | } 60 | 61 | f1Errors ++ f2Errors ++ f3Errors 62 | } 63 | 64 | private[this] def isReference(typ: String): Boolean = { 65 | typ.endsWith("_reference") 66 | } 67 | 68 | private[this] def validateNames(f1: Field, f2: Field, f3: Field): Seq[String] = { 69 | validateName(1, "id", f1.name) ++ 70 | validateName(2, stripReference(stripPackage(f2.`type`)), f2.name) ++ 71 | validateName(3, stripReference(stripPackage(f3.`type`)), f3.name) 72 | } 73 | 74 | private[this] def validateName(index: Int, expected: String, actual: String): Seq[String] = { 75 | if (expected == actual) { 76 | Nil 77 | } else { 78 | Seq(s"Field $index '$actual' must be named '$expected'") 79 | } 80 | } 81 | 82 | private[this] def stripReference(typ: String): String = { 83 | typ.stripSuffix("_reference") 84 | } 85 | 86 | private[this] def stripPackage(typ: String): String = { 87 | typ.split("\\.").last 88 | } 89 | 90 | private[this] def validateModelName(expected: String, actual: String): Seq[String] = { 91 | if (expected == actual) { 92 | Nil 93 | } else { 94 | Seq(s"Model '$actual' must be named '$expected'") 95 | } 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/MinimumMaximum.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Field, Model, Operation, Parameter, Resource, Service} 5 | import scala.util.{Failure, Success, Try} 6 | 7 | /** - validates that any field with a maximum has the maximum set to 100 8 | * - validates that any field with a minimum has the minimum set to 0 or 1 9 | * - if there is a numeric default and a minimum, validates that default >= minimum 10 | * - every parameter that is an array should have a maximum of 100 (except if it is the expand parameter) 11 | */ 12 | case object MinimumMaximum extends Linter with Helpers { 13 | 14 | val GlobalMax = 100 15 | 16 | val CountryMax = 3 17 | val CurrencyMax = 3 18 | val LanguageMax = 2 19 | 20 | override def validate(service: Service): Seq[String] = { 21 | service.models.flatMap(validateModel) ++ service.resources.flatMap(validateResource(service, _)) 22 | } 23 | 24 | def validateModel(model: Model): Seq[String] = { 25 | model.fields.flatMap { validateField(model, _) } 26 | } 27 | 28 | def validateField(model: Model, field: Field): Seq[String] = { 29 | val minErrors = field.minimum match { 30 | case None => Nil 31 | case Some(min) => { 32 | field.default match { 33 | case None => Nil 34 | case Some(default) => { 35 | Try( 36 | default.toLong, 37 | ) match { 38 | case Success(d) => { 39 | if (d < min) { 40 | Seq(error(model, field, s"Default must be >= minimum[$min] and not $default")) 41 | } else { 42 | Nil 43 | } 44 | } 45 | case Failure(_) => { 46 | // Not a number - nothing to validate 47 | Nil 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | val maxErrors = field.maximum match { 56 | case None => Nil 57 | case Some(max) => 58 | field.name match { 59 | case c if isCountry(c) => 60 | if (max == CountryMax) { 61 | Nil 62 | } else { 63 | Seq(error(model, field, s"Maximum must be $CountryMax and not $max")) 64 | } 65 | 66 | case c if isCurrency(c) => 67 | if (max == CurrencyMax) { 68 | Nil 69 | } else { 70 | Seq(error(model, field, s"Maximum must be $CurrencyMax and not $max")) 71 | } 72 | 73 | case c if isLanguage(c) => 74 | if (max == LanguageMax) { 75 | Nil 76 | } else { 77 | Seq(error(model, field, s"Maximum must be $LanguageMax and not $max")) 78 | } 79 | 80 | case _ => 81 | field.minimum match { 82 | case Some(min) => { 83 | if (max >= min) { 84 | Nil 85 | } else { 86 | Seq(error(model, field, s"Maximum, if specified with minimum, must be >= $min and not $max")) 87 | } 88 | } 89 | case None => Nil 90 | } 91 | } 92 | } 93 | 94 | minErrors ++ maxErrors 95 | } 96 | 97 | def validateResource(service: Service, resource: Resource): Seq[String] = { 98 | resource.operations.flatMap(validateOperation(service, resource, _)) 99 | } 100 | 101 | def validateOperation(service: Service, resource: Resource, operation: Operation): Seq[String] = { 102 | operation.parameters.flatMap(validateParameter(service, resource, operation, _)) 103 | } 104 | 105 | def validateParameter(service: Service, resource: Resource, operation: Operation, param: Parameter): Seq[String] = { 106 | val minErrors = param.minimum match { 107 | case None => Nil 108 | case Some(min) => { 109 | if (min < 0) { 110 | Seq(error(resource, operation, param, s"Minimum must be >= 0 and not $min")) 111 | } else { 112 | Nil 113 | } 114 | } 115 | } 116 | 117 | val maxErrors = param.maximum match { 118 | case None => { 119 | if (isArray(param.`type`)) { 120 | Seq(error(resource, operation, param, s"Missing maximum")) 121 | } else { 122 | Nil 123 | } 124 | } 125 | 126 | case Some(max) => { 127 | if (isArray(param.`type`)) { 128 | val desiredMax = service.enums.find(_.name == baseType(param.`type`)) match { 129 | case Some(enum) => Some(enum.values.size) 130 | case None => { 131 | if (isPrimitiveType(param.`type`)) { 132 | Some(GlobalMax) 133 | } else { 134 | None 135 | } 136 | } 137 | } 138 | 139 | desiredMax match { 140 | case None => Nil 141 | case Some(expected) => { 142 | if (max == expected) { 143 | Nil 144 | } else if (param.name == ExpandName) { 145 | Nil 146 | } else { 147 | Seq(error(resource, operation, param, s"Maximum must be $expected and not $max")) 148 | } 149 | } 150 | } 151 | } else { 152 | Nil 153 | } 154 | } 155 | } 156 | 157 | minErrors ++ maxErrors 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/ModelsWithOrganizationField.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Model, Service} 5 | 6 | /** For models w/ field named organization, ensure organization's position is: 7 | * a. fourth (if first 3 fields are id, timestamp, type), to support journals b. second field (after id) c. first 8 | * field (if no id) 9 | */ 10 | case object ModelsWithOrganizationField extends Linter with Helpers { 11 | 12 | override def validate(service: Service): Seq[String] = { 13 | service.models.flatMap(validateModel) 14 | } 15 | 16 | def validateModel(model: Model): Seq[String] = { 17 | val fieldNames = model.fields.filter(_.required).map(_.name).toList 18 | val index = fieldNames.indexOf("organization") 19 | 20 | if (index < 0) { 21 | Nil 22 | } else { 23 | val position = fieldNames.take(3) match { 24 | case "event_id" :: "timestamp" :: "organization" :: _ => 2 25 | case "event_id" :: "timestamp" :: "id" :: _ => 3 26 | case "id" :: "timestamp" :: "type" :: _ => 3 27 | case "id" :: _ => 1 28 | case _ => 0 29 | } 30 | 31 | if (position == index) { 32 | Nil 33 | } else { 34 | Seq(error(model, s"Field[organization] must be in position[$position] and not[$index]")) 35 | } 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/PathsDoNotHaveTrailingSlash.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | import io.flow.lint.Linter 5 | 6 | case object PathsDoNotHaveTrailingSlash extends Linter with Helpers { 7 | 8 | override def validate(service: Service): Seq[String] = { 9 | service.resources.flatMap { resource => 10 | resource.operations.filter(_.path.endsWith("/")).map { op => 11 | error(resource, op, "Path cannot end with '/'") 12 | } 13 | } 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/ProxyQueryParameters.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Operation, Parameter, Resource, Service} 5 | 6 | /** We reserve 'method' and 'callback' for jsonp requests (as implemented by github.com/flowvault/proxy) 7 | * 8 | * 'envelope' reserved to wrap responses in an envelope (as HTTP 200) 9 | */ 10 | case object ProxyQueryParameters extends Linter with Helpers { 11 | 12 | private[this] val ReservedNames: Seq[String] = Seq("callback", "envelope", "method") 13 | 14 | override def validate(service: Service): Seq[String] = { 15 | nonHealthcheckResources(service).flatMap(validateResource) 16 | } 17 | 18 | def validateResource(resource: Resource): Seq[String] = { 19 | resource.operations.flatMap { op => 20 | op.parameters.flatMap { param => 21 | validateParameter(resource, op, param) 22 | } 23 | } 24 | } 25 | 26 | def validateParameter(resource: Resource, op: Operation, param: Parameter): Seq[String] = { 27 | if (ReservedNames.contains(param.name)) { 28 | Seq(error(resource, op, param, s"name is reserved for use only in https://github.com/flowvault/proxy")) 29 | } else { 30 | Nil 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/PublishedEventModels.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.apibuilder.spec.v0.models.{Model, Service} 4 | import io.flow.lint.Linter 5 | 6 | /** Published event models must look like: 7 | * 8 | * "organization_rates_published": { "fields": [ { "name": "event_id", "type": "string" }, { "name": "timestamp", 9 | * "type": "date-time-iso8601" }, { "name": "organization", "type": "string" }, { "name": "data", "type": 10 | * "organization_rates_data" } ] } 11 | */ 12 | case object PublishedEventModels extends Linter with Helpers { 13 | 14 | override def validate(service: Service): Seq[String] = { 15 | service.models 16 | .filter(m => !ignored(m.attributes, "published_event_model")) 17 | .filter(isPublishedEvent) 18 | .flatMap(validateModel) 19 | } 20 | 21 | private[this] val Suffixes = List( 22 | "published", 23 | ) 24 | 25 | private[this] def isPublishedEvent(model: Model): Boolean = { 26 | Suffixes.exists { s => model.name.endsWith(s"_$s") } 27 | } 28 | 29 | def validateModel(model: Model): Seq[String] = { 30 | model.fields.map(_.name).toList match { 31 | case "event_id" :: "timestamp" :: "organization" :: "data" :: Nil => { 32 | validateTypes(model) 33 | } 34 | 35 | case other => { 36 | Seq( 37 | error( 38 | model, 39 | "Published event models must contain exactly four fields: event_id, timestamp, organization, data. " + 40 | s"Your model was defined as: ${other.mkString(", ")}", 41 | ), 42 | ) ++ validateTypes(model) 43 | } 44 | } 45 | } 46 | 47 | private[this] def validateTypes(model: Model): Seq[String] = { 48 | val dataTypeName = model.name.split("_").dropRight(1).mkString("_") + "_data" 49 | validateFieldTypes( 50 | model, 51 | Map( 52 | "event_id" -> "string", 53 | "timestamp" -> "date-time-iso8601", 54 | "organization" -> "string", 55 | "data" -> dataTypeName, 56 | ), 57 | ) 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/SortAttribute.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Operation, Resource, Service} 5 | 6 | /** A rule that ensures a sort attribute exists for every operation that has a sort parameter. 7 | */ 8 | case object SortAttribute extends Linter with Helpers { 9 | 10 | override def validate(service: Service): Seq[String] = { 11 | service.resources 12 | .flatMap { resource: Resource => 13 | resource.operations 14 | .filter(_.parameters.exists(_.name == "sort")) 15 | .filter(op => !ignored(op.attributes, "sort")) 16 | .flatMap(validateOperationHasSortAttribute(resource, _)) 17 | } 18 | } 19 | 20 | def validateOperationHasSortAttribute(resource: Resource, operation: Operation): Seq[String] = { 21 | operation.attributes.find(_.name == "sort") match { 22 | case None => Seq(error(resource, operation, "Missing attribute named sort")) 23 | case Some(_) => Nil 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/SortParameterDefault.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Operation, Resource, Service} 5 | 6 | /** for resources w/ sort parameter: 7 | * - default to created_at if path ends with /versions 8 | * - default to name, -created_at if there is a name field of type string 9 | * - otherwise default to -created_at 10 | */ 11 | case object SortParameterDefault extends Linter with Helpers { 12 | 13 | override def validate(service: Service): Seq[String] = { 14 | service.resources.flatMap(validateResource(service, _)) 15 | } 16 | 17 | def validateResource(service: Service, resource: Resource): Seq[String] = { 18 | resource.operations 19 | .filter(r => !ignored(r.attributes, "sort")) 20 | .filter(r => !ignored(r.attributes, "sort_parameter_default")) 21 | .flatMap(validateOperation(service, resource, _)) 22 | } 23 | 24 | def validateOperation(service: Service, resource: Resource, operation: Operation): Seq[String] = { 25 | operation.parameters.find(_.name == "sort") match { 26 | case None => { 27 | Nil 28 | } 29 | case Some(sort) => { 30 | sort.default match { 31 | case None => { 32 | Seq(error(resource, operation, "Parameter sort requires a default")) 33 | } 34 | case Some(default) => { 35 | val expected = computeDefaults(service, operation) 36 | expected.contains(default) match { 37 | case true => Nil 38 | case false => { 39 | Seq( 40 | error( 41 | resource, 42 | operation, 43 | s"Parameter sort default expected to be[${expected.mkString(" or ")}] and not[$default]", 44 | ), 45 | ) 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | def computeDefaults(service: Service, operation: Operation): Seq[String] = { 55 | operation.path.endsWith("/versions") match { 56 | case true => { 57 | Seq("journal_timestamp") 58 | } 59 | case false => { 60 | model(service, operation) match { 61 | case None => { 62 | Seq("-created_at", "name") 63 | } 64 | case Some(model) => { 65 | model.fields.find(f => f.name == "name" && f.`type` == "string") match { 66 | case None => Seq("-created_at") 67 | case Some(_) => Seq("name") 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/StandardResponse.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Method, Operation, Resource, Response, Service} 5 | import io.apibuilder.spec.v0.models.{ResponseCodeInt, ResponseCodeOption, ResponseCodeUndefinedType} 6 | 7 | /** Response codes: 8 | * 9 | * GET: 200, 401 POST: 200,401, 422 PUT: 401, 422 DELETE: 204, 401, 404 10 | * 11 | * - 204 - verify type: unit 12 | * - 401 - verify type: unit, description: "unauthorized request" 13 | */ 14 | case object StandardResponse extends Linter with Helpers { 15 | 16 | val RequiredResponseCodes: Map[Method, Seq[Int]] = Map( 17 | Method.Get -> Seq(200, 401), 18 | Method.Patch -> Seq(401, 404, 422), 19 | Method.Post -> Seq(401, 422), 20 | Method.Put -> Seq(401, 422), 21 | Method.Delete -> Seq(401, 404), 22 | Method.Head -> Nil, 23 | Method.Connect -> Nil, 24 | Method.Options -> Nil, 25 | Method.Trace -> Nil, 26 | ) 27 | 28 | override def validate(service: Service): Seq[String] = { 29 | nonHealthcheckResources(service).flatMap(validateResource(_)) 30 | } 31 | 32 | def validateResource(resource: Resource): Seq[String] = { 33 | resource.operations.filter(op => !ignored(op.attributes, "response_codes")).flatMap(validateOperation(resource, _)) 34 | } 35 | 36 | def validateOperation(resource: Resource, operation: Operation): Seq[String] = { 37 | validateResponses(resource, operation) ++ 38 | validateStandardResponsesHaveNoDescription(resource, operation) ++ 39 | operation.responses.flatMap(validateResponse(resource, operation, _)) 40 | } 41 | 42 | def validateStandardResponsesHaveNoDescription(resource: Resource, operation: Operation): Seq[String] = { 43 | RequiredResponseCodes(operation.method).flatMap { code => 44 | operation.responses.find(_.code == ResponseCodeInt(code)) match { 45 | case None => Nil 46 | case Some(response) => { 47 | response.description match { 48 | case None => Nil 49 | case Some(_) => 50 | Seq( 51 | error( 52 | resource, 53 | operation, 54 | response, 55 | "Must not have a description as this is a globally standard response", 56 | ), 57 | ) 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | def validateResponses(resource: Resource, operation: Operation): Seq[String] = { 65 | val actualCodes = operation.responses.flatMap { r => 66 | r.code match { 67 | case ResponseCodeInt(n) => Some(n) 68 | case ResponseCodeOption.Default => Some(200) 69 | case ResponseCodeOption.UNDEFINED(_) | ResponseCodeUndefinedType(_) => None 70 | } 71 | }.sorted 72 | 73 | RequiredResponseCodes.get(operation.method).map(_.sorted) match { 74 | case None => { 75 | Seq( 76 | error( 77 | resource, 78 | operation, 79 | s"Missing documentation for required response codes for method[${operation.method}]", 80 | ), 81 | ) 82 | } 83 | case Some(expected) => { 84 | expected.filter(code => !actualCodes.contains(code)) match { 85 | case Nil => Nil 86 | case missing if missing == Seq(200) && actualCodes.contains(302) => Nil // Treat 302 as success 87 | case missing => Seq(error(resource, operation, s"Missing response codes: " + missing.mkString(", "))) 88 | } 89 | } 90 | } 91 | } 92 | 93 | def validateResponse(resource: Resource, operation: Operation, response: Response): Seq[String] = { 94 | response.code match { 95 | case ResponseCodeInt(200) | ResponseCodeOption.Default => { 96 | Nil 97 | } 98 | case ResponseCodeOption.UNDEFINED(_) | ResponseCodeUndefinedType(_) => { 99 | Seq(error(resource, operation, "Must document a valid return code")) 100 | } 101 | case ResponseCodeInt(n) => { 102 | n match { 103 | case 204 if pathIdentifiesResource(operation.path) => compare(resource, operation, response, "unit") 104 | case 401 => compare(resource, operation, response, "unit") 105 | case 422 => compare(resource, operation, response, "*_error") 106 | case _ => Nil 107 | } 108 | } 109 | } 110 | } 111 | 112 | /** True if last path component is a variable. Used to differentiate between: 113 | * 114 | * DELETE /shopify/carts/:id 115 | * 116 | * DELETE /shopify/carts/:id/promo 117 | * 118 | * where in the second example we want to allow HTTP 200 response (resource itself was not deleted) 119 | */ 120 | def pathIdentifiesResource(path: String): Boolean = { 121 | path.split("/").lastOption.getOrElse("").startsWith(":") 122 | } 123 | 124 | def compare( 125 | resource: Resource, 126 | operation: Operation, 127 | response: Response, 128 | datatype: String, 129 | ): Seq[String] = { 130 | typeMatches(response.`type`, datatype) match { 131 | case true => { 132 | Nil 133 | } 134 | case false => { 135 | Seq(error(resource, operation, response, s"response must be of type ${datatype} and not ${response.`type`}")) 136 | } 137 | } 138 | } 139 | 140 | def typeMatches( 141 | typ: String, 142 | pattern: String, 143 | ): Boolean = { 144 | typ == pattern match { 145 | case true => true 146 | case false => pattern.startsWith("*") && typ.endsWith(pattern.drop(1)) 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/UnionTypesHaveCommonDiscriminator.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Service, Union} 5 | 6 | /** Validates that every union type must have a discriminator with value 'discriminator' 7 | */ 8 | case object UnionTypesHaveCommonDiscriminator extends Linter with Helpers { 9 | 10 | private val ValidNames = Seq("discriminator", "type", "code") 11 | 12 | override def validate(service: Service): Seq[String] = { 13 | service.unions.flatMap(validateUnion) 14 | } 15 | 16 | def validateUnion(union: Union): Seq[String] = { 17 | lazy val expected = expectedDiscriminator(union.name) 18 | lazy val expectedStr = expected.map("'" + _ + "'").mkString("(", ", ", ")") 19 | 20 | union.discriminator match { 21 | case None => Seq(error(union, s"Must have a discriminator with value one of $expectedStr")) 22 | case Some(actual) if expected.contains(actual) => Nil 23 | case Some(actual) => Seq(error(union, s"Discriminator must have value one of $expectedStr and not '$actual'")) 24 | } 25 | } 26 | 27 | private[this] def expectedDiscriminator(typeName: String): Seq[String] = 28 | if (isError(typeName)) 29 | Seq("code") 30 | else if (typeName == "localized_price") 31 | // one time hack - do not repeat 32 | Seq("key") 33 | else 34 | ValidNames 35 | } 36 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/linters/VersionModels.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import io.flow.lint.Linter 4 | import io.apibuilder.spec.v0.models.{Field, Model, Service} 5 | 6 | /** Keep all _version models w/ same structure - 3 leading fields, then a typed one 7 | * 8 | * "user_version": { "fields": [ { "name": "id", "type": "string" }, { "name": "timestamp", "type": "date-time-iso8601" 9 | * }, { "name": "type", "type": "io.flow.common.v0.enums.change_type" }, { "name": "user", "type": "user" } ] } 10 | */ 11 | case object VersionModels extends Linter with Helpers { 12 | 13 | override def validate(service: Service): Seq[String] = { 14 | service.models.filter(_.name.endsWith("_version")).flatMap(validateModel(_)) 15 | } 16 | 17 | def validateModel(model: Model): Seq[String] = { 18 | assert(model.name.endsWith("_version")) 19 | val baseModelName = model.name.replace("_version", "") 20 | 21 | model.fields.size == 4 match { 22 | case true => { 23 | Seq( 24 | findField(model, 0, "id"), 25 | findField(model, 1, "timestamp"), 26 | findField(model, 2, "type"), 27 | findField(model, 3, baseModelName), 28 | ).flatten match { 29 | case idField :: timestampField :: typeField :: modelField :: Nil => { 30 | validateType(model, idField, "string") ++ 31 | validateType(model, timestampField, "date-time-iso8601") ++ 32 | validateType(model, typeField, "io.flow.common.v0.enums.change_type") ++ 33 | validateTypes( 34 | model, 35 | modelField, 36 | Seq( 37 | baseModelName, 38 | s"${baseModelName}_summary", 39 | s"expandable_$baseModelName", 40 | ), 41 | ) 42 | } 43 | case _ => { 44 | Seq(error(model, s"Must have exactly 4 fields: id, timestamp, type, $baseModelName")) 45 | } 46 | } 47 | } 48 | case false => { 49 | Seq(error(model, s"Must have exactly 4 fields: id, timestamp, type, $baseModelName")) 50 | } 51 | } 52 | } 53 | 54 | private[this] def findField(model: Model, index: Int, name: String): Option[Field] = { 55 | model.fields.lift(index).flatMap { f => 56 | f.name == name match { 57 | case true => Some(f) 58 | case false => None 59 | } 60 | } 61 | } 62 | 63 | private[this] def validateType(model: Model, field: Field, datatype: String): Seq[String] = { 64 | validateTypes(model, field, Seq(datatype)) 65 | } 66 | 67 | private[this] def validateTypes(model: Model, field: Field, datatypes: Seq[String]): Seq[String] = { 68 | assert(!datatypes.isEmpty) 69 | datatypes.find { datatype => 70 | field.`type` == datatype || field.`type`.endsWith(s".$datatype") || field.`type`.endsWith( 71 | s".expandable_$datatype", 72 | ) 73 | } match { 74 | case None => { 75 | Seq( 76 | error(model, field, s"Must have type ${datatypes.mkString(" or ")} and not ${field.`type`}"), 77 | ) 78 | } 79 | case _ => { 80 | field.required match { 81 | case false => Seq(error(model, field, s"Must be required")) 82 | case true => Nil 83 | } 84 | } 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/lint/util/Expansions.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.util 2 | 3 | import io.flow.lint.linters.Helpers 4 | 5 | object Expansions extends Helpers { 6 | 7 | /** Accepts a list of field types, returning the list of possible examples. 8 | * 9 | * Examples: Seq("id", "name") => Nil Seq("id", "expandable_organization") => Seq("organization") 10 | */ 11 | def fromFieldTypes(fields: Seq[String]): Seq[String] = { 12 | fields.flatMap(toName) 13 | } 14 | 15 | private[this] def toName(field: String): Option[String] = { 16 | val idx = field.lastIndexOf(".") 17 | val name = if (idx < 0) { baseType(field) } 18 | else { baseType(field.substring(idx + 1)) } 19 | if (name.startsWith("expandable_")) { 20 | Some(name.replace("expandable_", "")) 21 | } else { 22 | None 23 | } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/oneapi/AllTypeNames.scala: -------------------------------------------------------------------------------- 1 | package io.flow.oneapi 2 | 3 | import apibuilder.ApiBuilderHelperImpl 4 | import io.apibuilder.spec.v0.models._ 5 | import io.apibuilder.validation.{ApiBuilderService, MultiServiceImpl, TypeName} 6 | 7 | import java.util.UUID 8 | 9 | /** Finds all declared types in a service, iterating through all models, fields, unions, responses, etc. 10 | */ 11 | object AllTypeNames { 12 | 13 | private[this] val defaultNamespace = UUID.randomUUID().toString 14 | 15 | def findNamespaces(service: Service): Set[String] = { 16 | find(service).flatMap { t => 17 | Some(TypeName.parse(t, defaultNamespace = defaultNamespace).namespace).filterNot(_ == defaultNamespace) 18 | } 19 | } 20 | 21 | def find(service: Service): Set[String] = { 22 | val helper = ApiBuilderHelperImpl(MultiServiceImpl(List(ApiBuilderService(service)))) 23 | 24 | ( 25 | service.models.flatMap(find) ++ 26 | service.unions.flatMap(find) ++ 27 | service.interfaces.flatMap(find) ++ 28 | service.resources.flatMap(find) 29 | ).map(helper.baseType(service, _)).toSet 30 | } 31 | 32 | private[this] def find(model: Model): Seq[String] = { 33 | model.fields.flatMap(find) 34 | } 35 | 36 | private[this] def find(field: Field): Seq[String] = { 37 | Seq(field.`type`) 38 | } 39 | 40 | private[this] def find(union: Union): Seq[String] = { 41 | union.types.flatMap(find) 42 | } 43 | 44 | private[this] def find(unionType: UnionType): Seq[String] = { 45 | Seq(unionType.`type`) 46 | } 47 | 48 | private[this] def find(interface: Interface): Seq[String] = { 49 | interface.fields.flatMap(find) 50 | } 51 | 52 | private[this] def find(resource: Resource): Seq[String] = { 53 | Seq(resource.`type`) ++ resource.operations.flatMap(find) 54 | } 55 | 56 | private[this] def find(operation: Operation): Seq[String] = { 57 | operation.body.toSeq.map(_.`type`) ++ 58 | operation.parameters.flatMap(find) ++ 59 | operation.responses.flatMap(find) 60 | } 61 | 62 | private[this] def find(parameter: Parameter): Seq[String] = { 63 | Seq(parameter.`type`) 64 | } 65 | 66 | private[this] def find(response: Response): Seq[String] = { 67 | Seq(response.`type`) ++ response.headers.getOrElse(Nil).map(_.`type`) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/oneapi/Controller.scala: -------------------------------------------------------------------------------- 1 | package io.flow.oneapi 2 | 3 | import cats.data.Validated.{Invalid, Valid} 4 | import io.apibuilder.spec.v0.models.Service 5 | import io.flow.build.{Application, BuildConfig, BuildType, DownloadCache} 6 | 7 | case class Controller() extends io.flow.build.Controller { 8 | 9 | override val name = "OneApi" 10 | override val command = "oneapi" 11 | 12 | def run( 13 | buildType: BuildType, 14 | buildConfig: BuildConfig, 15 | downloadCache: DownloadCache, 16 | services: Seq[Service], 17 | )(implicit 18 | ec: scala.concurrent.ExecutionContext, 19 | ): Unit = { 20 | val eventService: Seq[Service] = ( 21 | buildType match { 22 | case BuildType.ApiEvent | BuildType.ApiInternalEvent | BuildType.ApiPartner | BuildType.ApiMiscEvent => None 23 | case BuildType.Api => Some(BuildType.ApiEvent.toString) 24 | case BuildType.ApiInternal => Some(BuildType.ApiInternalEvent.toString) 25 | case BuildType.ApiMisc => Some(BuildType.ApiMiscEvent.toString) 26 | } 27 | ) match { 28 | case None => Nil 29 | case Some(applicationKey) => { 30 | downloadCache.downloadService(Application.latest("flow", applicationKey)) match { 31 | case Left(errors) => sys.error(s"Failed to download API Builder application flow/$applicationKey: $errors") 32 | case Right(service) => Seq(service) 33 | } 34 | } 35 | } 36 | 37 | val all = services ++ eventService 38 | println("Building single API from: " + all.map(_.name).mkString(", ")) 39 | OneApi(buildType, downloadCache, all).process() match { 40 | case Invalid(errs) => { 41 | println(s"Errors from building single API:\n - ${errs.toNonEmptyList.toList.mkString("\n")}") 42 | errs.toNonEmptyList.toList.foreach(addError) 43 | } 44 | 45 | case Valid(service) => { 46 | import io.apibuilder.spec.v0.models.json._ 47 | import play.api.libs.json._ 48 | 49 | val path = buildConfig.output.resolve(s"flow-$buildType.json").toFile 50 | new java.io.PrintWriter(path) { 51 | write(Json.prettyPrint(Json.toJson(service))) 52 | close() 53 | } 54 | println(s"One API file created. See: $path") 55 | } 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/oneapi/Defaults.scala: -------------------------------------------------------------------------------- 1 | package io.flow.oneapi 2 | 3 | object Defaults { 4 | 5 | val FieldDescriptions = Map( 6 | "id" -> "Globally unique identifier", 7 | "number" -> "Client's unique identifier for this object", 8 | "organization" -> "Refers to your organization's account identifier", 9 | ) 10 | 11 | val ParameterDescriptions = Map( 12 | "id" -> "Filter by one or more IDs of this resource", 13 | "limit" -> "The maximum number of results to return", 14 | "offset" -> "The number of results to skip before returning results", 15 | "organization" -> "Refers to your organization's account identifier", 16 | ) 17 | 18 | val ResponseDescriptions = Map( 19 | "200" -> "Successful response", 20 | "201" -> "Operation succeeded and the resource was created", 21 | "204" -> "Operation succeeded. No content is returned", 22 | "401" -> "Authorization failed", 23 | "404" -> "Resource was not found", 24 | "422" -> "One or more errors were found with the data sent in the request. The body of the response contains specific details on what data failed validation.", 25 | ) 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/oneapi/Module.scala: -------------------------------------------------------------------------------- 1 | package io.flow.oneapi 2 | 3 | case class Module(name: String, serviceNames: Set[String]) { 4 | assert( 5 | name.toLowerCase() == name, 6 | "Module name must be in lower case", 7 | ) 8 | assert( 9 | serviceNames.forall { n => n.toLowerCase() == n }, 10 | "All service names must be in lower case", 11 | ) 12 | } 13 | 14 | object Module { 15 | 16 | val General: Module = Module( 17 | "general", 18 | Set("common", "feed", "healthcheck", "link", "organization", "search", "session", "token", "user"), 19 | ) 20 | val Webhook: Module = Module("webhook", Set("webhook")) 21 | 22 | val All = Seq( 23 | Module("localization", Set("catalog", "experience")), 24 | Module("pricing", Set("currency")), 25 | Module("landed cost", Set("harmonization")), 26 | Module("payment", Set("payment")), 27 | Module("logistics", Set("fulfillment", "inventory", "label", "ratecard", "return", "tracking")), 28 | Webhook, 29 | Module("customer service", Set.empty), 30 | Module("geolocation", Set("location")), 31 | Module("reference", Set("reference")), 32 | Module("partner", Set("partner")), 33 | Module("calculator", Set("calculator")), 34 | General, 35 | ) 36 | 37 | def findByServiceName(name: String): Option[Module] = { 38 | All.find(_.serviceNames.map(_.toLowerCase).contains(name.toLowerCase)) 39 | } 40 | 41 | def findByModuleName(name: String): Option[Module] = { 42 | All.find(_.name.toLowerCase == name.toLowerCase) 43 | } 44 | 45 | def moduleSortIndex(name: String): Int = { 46 | findByModuleName(name) match { 47 | case Some(module) => All.indexOf(module) 48 | case None => Int.MaxValue 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/oneapi/OperationSort.scala: -------------------------------------------------------------------------------- 1 | package io.flow.oneapi 2 | 3 | import io.apibuilder.spec.v0.models.{Method, Operation} 4 | 5 | object OperationSort { 6 | 7 | /** Sort the operations in order so that any statically declared references sort before equivalent dynamically 8 | * declared references. For example: 9 | * 10 | * - /:organization/experiences/:key 11 | * - /:organization/experiences/items 12 | * 13 | * We need the /items path to sort before /:key else it never resolves 14 | */ 15 | def key(op: Operation): String = { 16 | ( 17 | op.path.split("/").filter(_.nonEmpty).map { p => 18 | if (p.startsWith(":")) { 19 | // Path component has a dynamic element in it - push to end 20 | s"z_$p" 21 | } else { 22 | s"a_$p" 23 | } 24 | } ++ Seq(methodSortOrder(op.method).toString) 25 | ).mkString(":") 26 | } 27 | 28 | /** Returns a numeric index by which we can sort methods. This allows us to present, for example, all operations with 29 | * a GET Method first. 30 | */ 31 | private[this] def methodSortOrder(method: Method): Int = { 32 | method match { 33 | case Method.Get => 1 34 | case Method.Post => 2 35 | case Method.Put => 3 36 | case Method.Patch => 4 37 | case Method.Delete => 5 38 | case Method.Connect => 6 39 | case Method.Head => 7 40 | case Method.Options => 8 41 | case Method.Trace => 9 42 | case Method.UNDEFINED(_) => 10 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/proxy/ApiBuildAttributes.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | import play.api.libs.json.{JsString, JsValue} 5 | 6 | /** Helper to parse the values defined for the attribute named 'api-build' 7 | */ 8 | case class ApiBuildAttributes(services: Seq[Service]) { 9 | 10 | def host(serviceName: String): Option[String] = { 11 | get(serviceName, "host").map(_.asInstanceOf[JsString].value) 12 | } 13 | 14 | private[this] def get(serviceName: String, attributeName: String): Option[JsValue] = { 15 | services.find(_.name.toLowerCase == serviceName.toLowerCase) match { 16 | case None => None 17 | case Some(svc) => apiBuildAttributeValue(svc, attributeName) 18 | } 19 | } 20 | 21 | private[this] def apiBuildAttributeValue(service: Service, name: String): Option[JsValue] = { 22 | service.attributes.find(_.name == "api-build") match { 23 | case None => None 24 | case Some(attr) => attr.value.value.get(name) 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/proxy/Controller.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | import io.flow.build.{Application, BuildConfig, BuildType, DownloadCache} 5 | import io.flow.registry.v0.{Client => RegistryClient} 6 | import play.api.libs.json.Json 7 | 8 | import java.io.File 9 | import java.nio.file.Path 10 | 11 | case class Controller() extends io.flow.build.Controller { 12 | 13 | /** Allowlist of applications in the 'api' repo that do not exist in registry 14 | */ 15 | private[this] val ExcludeAllowList = Seq("common", "healthcheck", "usage", "gift-card") 16 | 17 | /** This is the hostname of the services when running in docker on our development machines. 18 | */ 19 | private[this] val DockerHostname = "172.17.0.1" 20 | 21 | private[this] val DevelopmentHostname = "localhost" 22 | 23 | override val name = "Proxy" 24 | override val command = "proxy" 25 | 26 | private def buildUserPermissionsFile( 27 | buildType: BuildType, 28 | output: Path, 29 | services: Seq[Service], 30 | ): Unit = { 31 | val routes = services.flatMap(s => 32 | s.resources.flatMap(r => 33 | r.operations.flatMap { o => 34 | o.attributes.find(_.name == "auth") match { 35 | case Some(a) => { 36 | val ts = a.value \ "techniques" 37 | val rs = a.value \ "roles" 38 | ts.as[Seq[String]] 39 | .filterNot(_ == "user") 40 | .map(t => (t, Map("method" -> o.method.toString, "path" -> o.path))) ++ 41 | rs.asOpt[Seq[String]] 42 | .map(r => 43 | r.map { t => 44 | (t, Map("method" -> o.method.toString, "path" -> o.path)) 45 | }, 46 | ) 47 | .getOrElse(Nil) 48 | } 49 | case None => { 50 | Seq(("anonymous", Map("method" -> o.method.toString, "path" -> o.path))) 51 | } 52 | } 53 | }, 54 | ), 55 | ) 56 | val rs = routes.groupBy(_._1).map(r => (r._1, Map("routes" -> r._2.map(_._2).distinct))) 57 | val m = Json.toJson(rs) 58 | 59 | val path = output.resolve(s"${buildType}-authorization.json").toFile 60 | writeToFile(path, Json.prettyPrint(m)) 61 | println(s" - $path") 62 | } 63 | 64 | def run( 65 | buildType: BuildType, 66 | buildConfig: BuildConfig, 67 | downloadCache: DownloadCache, 68 | allServices: Seq[Service], 69 | )(implicit 70 | ec: scala.concurrent.ExecutionContext, 71 | ): Unit = { 72 | val services = allServices.filter { s => s.resources.nonEmpty }.filterNot { s => 73 | ExcludeAllowList.exists(ew => s.name.startsWith(ew)) 74 | } 75 | 76 | val serviceHostResolver = ServiceHostResolver(allServices) 77 | 78 | val version = downloadCache.downloadService(Application.latest("flow", buildType.key)) match { 79 | case Left(error) => sys.error(s"Failed to download '$buildType' service from API Builder: $error") 80 | case Right(svc) => svc.version 81 | } 82 | 83 | println("Building authorization from: " + services.map(_.name).mkString(", ")) 84 | buildUserPermissionsFile(buildType, buildConfig.output, services) 85 | 86 | println("Building proxy from: " + services.map(_.name).mkString(", ")) 87 | 88 | val registryClient = new RegistryClient() 89 | try { 90 | buildProxyFile(buildType, buildConfig.output, services, version, "production") { service => 91 | s"${buildConfig.protocol}://${serviceHostResolver.host(service.name)}.${buildConfig.domain}" 92 | } 93 | 94 | val cache = RegistryApplicationCache(registryClient) 95 | 96 | def externalPort(service: Service): Long = cache.externalPort( 97 | registryName = serviceHostResolver.host(service.name), 98 | serviceName = service.name, 99 | ) 100 | 101 | buildProxyFile(buildType, buildConfig.output, services, version, "development") { service => 102 | s"http://$DevelopmentHostname:${externalPort(service)}" 103 | } 104 | 105 | buildProxyFile(buildType, buildConfig.output, services, version, "workstation") { service => 106 | s"http://$DockerHostname:${externalPort(service)}" 107 | } 108 | } finally { 109 | registryClient.closeAsyncHttpClient() 110 | } 111 | } 112 | 113 | private[this] def buildProxyFile( 114 | buildType: BuildType, 115 | output: Path, 116 | services: Seq[Service], 117 | version: String, 118 | env: String, 119 | )( 120 | hostProvider: Service => String, 121 | ): Unit = { 122 | services.toList match { 123 | case Nil => { 124 | println(s" - $env: No services - skipping proxy file") 125 | } 126 | 127 | case _ => { 128 | val serversYaml = services 129 | .map { service => 130 | Seq( 131 | s"- name: ${service.name}", 132 | s" host: ${hostProvider(service)}", 133 | ).mkString("\n") 134 | } 135 | .mkString("\n") 136 | 137 | val operationsYaml = services 138 | .flatMap { service => 139 | service.resources.flatMap(_.operations).map { op => 140 | Seq( 141 | s"- method: ${op.method.toString.toUpperCase}", 142 | s" path: ${op.path}", 143 | s" server: ${service.name}", 144 | ).mkString("\n") 145 | } 146 | } 147 | .mkString("\n") 148 | 149 | val all = s"""version: $version 150 | 151 | servers: 152 | ${Text.indent(serversYaml, 2)} 153 | 154 | operations: 155 | ${Text.indent(operationsYaml, 2)} 156 | """ 157 | 158 | val path = output.resolve(s"${buildType}-proxy.$env.config").toFile 159 | writeToFile(path, all) 160 | println(s" - $env: $path") 161 | } 162 | } 163 | } 164 | 165 | private[this] def writeToFile(path: File, contents: String): Unit = { 166 | import java.io.{BufferedWriter, FileWriter} 167 | 168 | val bw = new BufferedWriter(new FileWriter(path)) 169 | try { 170 | bw.write(contents) 171 | } finally { 172 | bw.close() 173 | } 174 | } 175 | 176 | } 177 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/proxy/RegistryApplicationCache.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | import io.flow.registry.v0.models.Application 4 | import io.flow.registry.v0.{Client => RegistryClient} 5 | 6 | import scala.annotation.tailrec 7 | import scala.concurrent.duration.Duration 8 | import scala.concurrent.{Await, ExecutionContext, Future} 9 | 10 | /** Cache to lookup information from the registry. This cache is filled on instantiation and will not pick up any 11 | * applications added to the registry after loading (i.e. there is no refresh). 12 | */ 13 | private[proxy] case class RegistryApplicationCache( 14 | client: RegistryClient, 15 | )(implicit ec: ExecutionContext) { 16 | 17 | private[this] val cache: Map[String, Application] = load( 18 | cache = scala.collection.mutable.Map[String, Application](), 19 | offset = 0, 20 | ) 21 | 22 | /** Get the application with the specified name from the registry. 23 | * 24 | * @param name 25 | * Application name in registry 26 | */ 27 | def get(name: String): Option[Application] = { 28 | cache.get(name) 29 | } 30 | 31 | /** Returns the external port for the application with the specified name, throwing an error if the application does 32 | * not exist in the registry. 33 | * 34 | * @param registryName 35 | * Application name in registry (ex. experience) 36 | * @param serviceName 37 | * Actual service name (ex. experience-internal) 38 | */ 39 | def externalPort(registryName: String, serviceName: String): Long = { 40 | val app = get(registryName).getOrElse { 41 | sys.error( 42 | s"Could not find application named[$serviceName] in Registry at[${client.baseUrl}]. Either add the application to the registry or add an attribute named api-build/host to the apibuilder spec. The context of this error is that as we are building the proxy configuration file, we do not know how to route traffic to the resources defined in this spec.", 43 | ) 44 | } 45 | app.ports.headOption.map(_.external).getOrElse { 46 | sys.error(s"Application named[$registryName] does not have any ports in Registry at[${client.baseUrl}]") 47 | } 48 | } 49 | 50 | @tailrec 51 | private[this] def load( 52 | cache: scala.collection.mutable.Map[String, Application], 53 | offset: Long, 54 | ): Map[String, Application] = { 55 | val limit = 100L 56 | val results = Await.result( 57 | getFromRegistry( 58 | limit = limit, 59 | offset = offset, 60 | ), 61 | Duration(5, "seconds"), 62 | ) 63 | 64 | results.map { apps => 65 | apps.map { app => 66 | cache += (app.id -> app) 67 | } 68 | } 69 | 70 | if (results.map(_.size).getOrElse(0) >= limit) { 71 | load(cache, offset + limit) 72 | } else { 73 | cache.toMap 74 | } 75 | } 76 | 77 | private[this] def getFromRegistry(limit: Long, offset: Long)(implicit 78 | ec: scala.concurrent.ExecutionContext, 79 | ): Future[Option[Seq[Application]]] = { 80 | client.applications 81 | .get(limit = limit, offset = offset) 82 | .map { apps => 83 | Some(apps) 84 | } 85 | .recover { 86 | case io.flow.registry.v0.errors.UnitResponse(404) => { 87 | None 88 | } 89 | 90 | case ex: Throwable => { 91 | sys.error(s"Error fetching applications from registry at[${client.baseUrl}]; $ex") 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/proxy/Route.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | case class Route(method: String, path: String) extends Ordered[Route] { 4 | 5 | private val sortKey = s"${path.toLowerCase}:${method.toLowerCase}" 6 | 7 | def yaml = { 8 | s"$method $path" 9 | } 10 | 11 | override def compare(that: Route) = { 12 | sortKey.compare(that.sortKey) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/proxy/ServiceHostResolver.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | 5 | case class ServiceHostResolver(services: Seq[Service]) { 6 | 7 | private[this] val apiBuildAttributes = ApiBuildAttributes(services) 8 | 9 | def host(serviceName: String): String = { 10 | apiBuildAttributes.host(serviceName).getOrElse { 11 | val formattedName = Text.stripSuffix( 12 | Text.stripSuffix(serviceName.toLowerCase, "-internal-event"), 13 | "-internal", 14 | ) 15 | apiBuildAttributes.host(formattedName).getOrElse { 16 | formattedName 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/proxy/Text.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | object Text { 4 | 5 | def indent(s: String, width: Int = 2): String = { 6 | s.split("\n") 7 | .map { value => 8 | if (value.trim == "") { 9 | "" 10 | } else { 11 | (" " * width) + value 12 | } 13 | } 14 | .mkString("\n") 15 | } 16 | 17 | def stripSuffix(value: String, suffix: String): String = { 18 | if (value.endsWith(suffix)) { 19 | value.substring(0, value.length - suffix.length) 20 | } else { 21 | value 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/stream/ApiBuilderUtils.scala: -------------------------------------------------------------------------------- 1 | package io.flow.stream 2 | 3 | object ApiBuilderUtils { 4 | def toClassName(name: String, quoteKeywords: Boolean = true): String = { 5 | val baseName = safeName( 6 | if (name == name.toUpperCase) { 7 | initCap(splitIntoWords(name).map(_.toLowerCase)).mkString("") 8 | } else { 9 | snakeToCamelCase(name).capitalize 10 | }, 11 | ) 12 | 13 | if (quoteKeywords) { 14 | quoteNameIfKeyword(baseName) 15 | } else { 16 | baseName 17 | } 18 | } 19 | 20 | def toPackageName(namespace: String, quoteKeywords: Boolean = true): String = { 21 | namespace.split("\\.").map(s => if (quoteKeywords) quoteNameIfKeyword(s) else s).mkString(".") + ".models" 22 | } 23 | 24 | private[this] val RemoveUnsafeCharacters = """([^0-9a-zA-Z\-\_])""".r 25 | 26 | private def safeName(name: String): String = { 27 | RemoveUnsafeCharacters.replaceAllIn(name, _ => "").replaceAll("\\.", "_").replaceAll("\\_+", "_").trim 28 | } 29 | 30 | private def initCap(parts: Seq[String]): String = { 31 | parts.map(s => s.capitalize).mkString("") 32 | } 33 | 34 | private[this] val WordDelimiterRx = "_|\\-|\\.|:|/| ".r 35 | 36 | private def splitIntoWords(value: String): Seq[String] = { 37 | WordDelimiterRx.split(camelCaseToUnderscore(value)).toSeq.map(_.trim).filter(!_.isEmpty) 38 | } 39 | 40 | private[this] val Capitals = """([A-Z])""".r 41 | 42 | private def camelCaseToUnderscore(phrase: String): String = { 43 | if (phrase == phrase.toUpperCase) { 44 | phrase.toLowerCase 45 | } else { 46 | val word = Capitals.replaceAllIn(phrase, m => s"_${m}").trim 47 | if (word.startsWith("_")) { 48 | word.slice(1, word.length) 49 | } else { 50 | word 51 | } 52 | } 53 | } 54 | 55 | private def snakeToCamelCase(value: String): String = { 56 | splitIntoWords(value).toList match { 57 | case Nil => "" 58 | case part :: rest => part + initCap(rest) 59 | } 60 | } 61 | 62 | private[this] val ReservedWords = Seq( 63 | "case", 64 | "catch", 65 | "class", 66 | "def", 67 | "do", 68 | "else", 69 | "extends", 70 | "false", 71 | "final", 72 | "finally", 73 | "for", 74 | "forSome", 75 | "if", 76 | "implicit", 77 | "import", 78 | "lazy", 79 | "match", 80 | "new", 81 | "null", 82 | "object", 83 | "override", 84 | "package", 85 | "private", 86 | "protected", 87 | "return", 88 | "sealed", 89 | "super", 90 | "this", 91 | "throw", 92 | "trait", 93 | "try", 94 | "true", 95 | "type", 96 | "val", 97 | "var", 98 | "while", 99 | "with", 100 | "yield", 101 | ).toSet 102 | 103 | private def quoteNameIfKeyword(name: String): String = { 104 | def isKeyword(value: String): Boolean = { 105 | ReservedWords.contains(value) 106 | } 107 | def needsQuoting(name: String): Boolean = { 108 | name.indexOf("[") >= 0 109 | } 110 | if (isKeyword(name) || needsQuoting(name)) { 111 | "`" + name + "`" 112 | } else { 113 | name 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/stream/EventType.scala: -------------------------------------------------------------------------------- 1 | package io.flow.stream 2 | 3 | import io.apibuilder.spec.v0.models.{Field, Model} 4 | 5 | sealed trait EventType { 6 | def eventName: String 7 | def typeName: String 8 | def discriminator: String 9 | } 10 | object EventType { 11 | case class Upserted( 12 | eventName: String, 13 | typeName: String, 14 | fieldName: String, 15 | payloadType: Model, 16 | idField: Field, 17 | discriminator: String, 18 | ) extends EventType { override val toString = "upserted" } 19 | case class Deleted( 20 | eventName: String, 21 | typeName: String, 22 | payloadType: Option[Model], 23 | idField: Field, 24 | discriminator: String, 25 | ) extends EventType { override val toString = "deleted" } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/stream/EventUnionTypeMatcher.scala: -------------------------------------------------------------------------------- 1 | package io.flow.stream 2 | 3 | import io.apibuilder.spec.v0.models.Field 4 | 5 | import scala.annotation.tailrec 6 | 7 | object EventUnionTypeMatcher { 8 | 9 | def matchFieldToPayloadType(field: Field, typeName: String): Boolean = { 10 | matchFieldName(typeName, field.name) && matchFieldType(typeName, field.`type`) 11 | } 12 | 13 | private def matchFieldName(typeName: String, fieldName: String): Boolean = { 14 | val typeNameList = typeName.split("_").filter(_.nonEmpty).toList 15 | val fieldNameList = fieldName.split("_").filter(_.nonEmpty).toList 16 | matchLists(fieldNameList, typeNameList) 17 | } 18 | 19 | private def matchFieldType(typeName: String, fieldType: String): Boolean = { 20 | val simpleType = fieldType.reverse.takeWhile(_ != '.').reverse 21 | val fieldTypeList = simpleType.split("_").filter(_.nonEmpty).toList 22 | val typeNameList = typeName.split("_").filter(_.nonEmpty).toList 23 | matchLists(typeNameList, fieldTypeList) || matchLists(fieldTypeList, typeNameList) 24 | } 25 | 26 | @tailrec 27 | private def matchLists(required: List[String], withExtras: List[String]): Boolean = (required, withExtras) match { 28 | case (Nil, _) => true 29 | case (_, Nil) => false 30 | case (head :: tail, _) => 31 | val shortened = withExtras.dropWhile(_ != head) 32 | if (shortened.isEmpty) { 33 | false 34 | } else { 35 | matchLists(tail, shortened.drop(1)) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/scala/io/flow/stream/KinesisStream.scala: -------------------------------------------------------------------------------- 1 | package io.flow.stream 2 | 3 | import io.apibuilder.spec.v0.models.{Enum, Model, Union} 4 | 5 | case class StreamDescriptor(streams: Seq[KinesisStream]) 6 | 7 | case class KinesisStream( 8 | streamName: String, 9 | shortName: String, 10 | capturedEvents: Seq[CapturedType], 11 | allModels: Seq[Model], 12 | allUnions: Seq[Union], 13 | allEnums: Seq[Enum], 14 | ) 15 | 16 | case class CapturedType( 17 | fieldName: String, 18 | typeName: String, 19 | modelType: Model, 20 | upsertedDiscriminator: String, 21 | deletedDiscriminator: String, 22 | deletedHasModel: Boolean, 23 | ) 24 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/ApplicationSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.build 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class ApplicationSpec extends AnyFunSpec with Matchers { 7 | 8 | it("parse valid strings") { 9 | Application.parse("flow/user") should be(Some(Application("flow", "user", "latest"))) 10 | Application.parse(" flow/user ") should be(Some(Application("flow", "user", "latest"))) 11 | Application.parse(" flow / user ") should be(Some(Application("flow", "user", "latest"))) 12 | Application.parse(" flow / user:1.2.3 ") should be(Some(Application("flow", "user", "1.2.3"))) 13 | } 14 | 15 | it("parse invalid strings") { 16 | Application.parse("flow") should be(None) 17 | Application.parse(" ") should be(None) 18 | Application.parse("flow/user/bar") should be(None) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/helpers/ServiceHostHelpers.scala: -------------------------------------------------------------------------------- 1 | package io.flow.helpers 2 | 3 | import io.apibuilder.spec.v0.models.{Attribute, Service} 4 | import io.flow.lint.Services 5 | import play.api.libs.json.Json 6 | 7 | trait ServiceHostHelpers { 8 | 9 | def serviceWithHost(name: String, host: Option[String] = None): Service = { 10 | Services.Base.copy( 11 | name = name, 12 | attributes = host.map { h => 13 | Attribute( 14 | name = "api-build", 15 | value = Json.obj("host" -> h), 16 | ) 17 | }.toSeq, 18 | ) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/AllAttributesAreWellKnownSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models.Service 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class AllAttributesAreWellKnownSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.AllAttributesAreWellKnown 10 | 11 | private[this] def build(names: Seq[String]): Service = { 12 | Services.Base.copy( 13 | models = Seq( 14 | Services.buildModel( 15 | name = "id", 16 | attributes = names.map { name => 17 | Services.buildAttribute(name = name) 18 | }, 19 | ), 20 | ), 21 | ) 22 | } 23 | 24 | private[this] val ErrorMsg = 25 | "Service contains an unknown attribute named 'auth' - remove this attribute or add to AllAttributesAreWellKnown.KnownAttributeNames in the api-build project (https://github.com/flowcommerce/api-build)" 26 | 27 | it("unsupported attribute") { 28 | linter.validate( 29 | build(Seq("auth")), 30 | ) shouldBe Seq(ErrorMsg) 31 | } 32 | 33 | it("unsupported attribute reported at most once") { 34 | linter.validate( 35 | build(Seq("auth", "auth")), 36 | ) shouldBe Seq(ErrorMsg) 37 | } 38 | 39 | it("other attribute are accepted") { 40 | def test(name: String) = linter.validate(build(Seq(name))) 41 | 42 | test("api-build") shouldBe Nil 43 | test("graphql") shouldBe Nil 44 | test("linter") shouldBe Nil 45 | test("non-crud") shouldBe Nil 46 | test("sort") shouldBe Nil 47 | test("io.flow.proxy") shouldBe Nil 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/BadNamesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | import play.api.libs.json.{JsObject, Json} 7 | 8 | class BadNamesSpec extends AnyFunSpec with Matchers { 9 | 10 | private[this] val linter = linters.BadNames 11 | 12 | def buildModel( 13 | fieldName: String, 14 | attributes: Seq[Attribute] = Nil, 15 | ): Service = { 16 | Services.Base.copy( 17 | models = Seq( 18 | Services.buildModel( 19 | "user", 20 | Seq( 21 | Services.buildField( 22 | name = fieldName, 23 | ), 24 | ), 25 | attributes, 26 | ), 27 | ), 28 | ) 29 | } 30 | 31 | def buildModelIgnored( 32 | fieldName: String, 33 | ): Service = { 34 | buildModel(fieldName, Seq(Attribute("linter", value = Json.parse("""{ "ignore": ["bad_names"] }""").as[JsObject]))) 35 | } 36 | 37 | def buildResource( 38 | paramName: String, 39 | attributes: Seq[Attribute] = Nil, 40 | ): Service = { 41 | buildModel("id").copy( 42 | resources = Seq( 43 | Services.buildResource( 44 | `type` = "user", 45 | operations = Seq( 46 | Services.buildSimpleOperation( 47 | path = "/users", 48 | responseType = "[user]", 49 | parameters = Seq( 50 | Services.buildParameter(paramName), 51 | ), 52 | attributes = attributes, 53 | ), 54 | ), 55 | ), 56 | ), 57 | ) 58 | } 59 | 60 | def buildResourceIgnored( 61 | paramName: String, 62 | ): Service = { 63 | buildResource( 64 | paramName, 65 | Seq(Attribute("linter", value = Json.parse("""{ "ignore": ["bad_names"] }""").as[JsObject])), 66 | ) 67 | } 68 | 69 | it("model fields") { 70 | linter.validate(buildModel("ip_address")) should be( 71 | Seq("Model user Field[ip_address]: Name must be 'ip'"), 72 | ) 73 | 74 | linter.validate(buildModel("postal_code")) should be( 75 | Seq("Model user Field[postal_code]: Name must be 'postal'"), 76 | ) 77 | } 78 | 79 | it("model fields ignored") { 80 | linter.validate(buildModelIgnored("ip_address")) should be( 81 | Nil, 82 | ) 83 | 84 | linter.validate(buildModelIgnored("postal_code")) should be( 85 | Nil, 86 | ) 87 | } 88 | 89 | it("resource parameter names") { 90 | linter.validate(buildResource("ip_address")) should be( 91 | Seq("Resource users GET /users Parameter ip_address: Name must be 'ip'"), 92 | ) 93 | 94 | linter.validate(buildResource("postal_code")) should be( 95 | Seq("Resource users GET /users Parameter postal_code: Name must be 'postal'"), 96 | ) 97 | } 98 | 99 | it("resource parameter names ignored") { 100 | linter.validate(buildResourceIgnored("ip_address")) should be( 101 | Nil, 102 | ) 103 | 104 | linter.validate(buildResourceIgnored("postal_code")) should be( 105 | Nil, 106 | ) 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/BeaconEventsMustHaveAttributesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class BeaconEventsMustHaveAttributesSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.BeaconEventsMustHaveAttributes 10 | 11 | def buildModel(fieldNames: Seq[String]): Model = { 12 | Services.buildModel( 13 | "user", 14 | fieldNames.map(name => 15 | Services.buildField( 16 | name = name, 17 | ), 18 | ), 19 | ) 20 | } 21 | 22 | def buildUnion(name: String, typeName: String): Union = { 23 | Services.buildUnion( 24 | name, 25 | types = Seq( 26 | Services.buildUnionType(typeName), 27 | ), 28 | ) 29 | } 30 | 31 | it("model not part of union") { 32 | linter.validate( 33 | Services.Base.copy( 34 | models = Seq(buildModel(Seq("id"))), 35 | ), 36 | ) should be(Nil) 37 | } 38 | 39 | it("model part of union") { 40 | val model = buildModel(Seq("id")) 41 | 42 | linter.validate( 43 | Services.Base.copy( 44 | models = Seq(model), 45 | unions = Seq(buildUnion("test", model.name)), 46 | ), 47 | ) should be(Nil) 48 | 49 | val union = buildUnion("event", model.name) 50 | linter.validate( 51 | Services.Base.copy( 52 | models = Seq(model), 53 | unions = Seq(union), 54 | ), 55 | ) should be( 56 | Seq( 57 | "Model user: Must have a field named 'attributes' of type 'beacon_attributes'", 58 | ), 59 | ) 60 | 61 | linter.validate( 62 | Services.Base.copy( 63 | models = Seq( 64 | model.copy( 65 | fields = Seq( 66 | Services.buildField("attributes"), 67 | ), 68 | ), 69 | ), 70 | unions = Seq(union), 71 | ), 72 | ) should be( 73 | Seq( 74 | "Model user Field[attributes]: Must not be required and have type 'beacon_attributes' and not 'string'", 75 | ), 76 | ) 77 | 78 | linter.validate( 79 | Services.Base.copy( 80 | models = Seq( 81 | model.copy( 82 | fields = Seq( 83 | Services.buildField("attributes", required = false, `type` = "beacon_attributes"), 84 | ), 85 | ), 86 | ), 87 | unions = Seq(union), 88 | ), 89 | ) should be(Nil) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/CommonFieldTypesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class CommonFieldTypesSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.CommonFieldTypes 10 | 11 | def buildService( 12 | fieldName: String, 13 | fieldType: String, 14 | ): Service = { 15 | Services.Base.copy( 16 | models = Seq( 17 | Services.buildModel( 18 | "user", 19 | Seq( 20 | Services.buildField( 21 | name = fieldName, 22 | `type` = fieldType, 23 | ), 24 | ), 25 | ), 26 | ), 27 | ) 28 | } 29 | 30 | it("id must be a string") { 31 | linter.validate(buildService("id", "string")) should be(Nil) 32 | linter.validate(buildService("id", "long")) should be( 33 | Seq("Model user Field[id]: Type must be 'string' and not long"), 34 | ) 35 | } 36 | 37 | it("number must be a string") { 38 | linter.validate(buildService("number", "string")) should be(Nil) 39 | linter.validate(buildService("number", "long")) should be( 40 | Seq("Model user Field[number]: Type must be 'string' and not long"), 41 | ) 42 | } 43 | 44 | it("guid must be a uuid") { 45 | linter.validate(buildService("guid", "uuid")) should be(Nil) 46 | linter.validate(buildService("guid", "string")) should be( 47 | Seq("Model user Field[guid]: Type must be 'uuid' and not string"), 48 | ) 49 | } 50 | 51 | it("email must be a string") { 52 | linter.validate(buildService("email", "string")) should be(Nil) 53 | linter.validate(buildService("email", "long")) should be( 54 | Seq("Model user Field[email]: Type must be 'string' and not long"), 55 | ) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/CommonParameterTypesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class CommonParameterTypesSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.CommonParameterTypes 10 | 11 | def buildService( 12 | paramName: String, 13 | paramType: String = "string", 14 | default: Option[String] = None, 15 | minimum: Option[Long] = None, 16 | maximum: Option[Long] = None, 17 | path: String = "/users", 18 | ): Service = { 19 | Services.Base.copy( 20 | models = Seq( 21 | Services.buildSimpleModel("user"), 22 | ), 23 | resources = Seq( 24 | Services.buildSimpleResource( 25 | `type` = "user", 26 | plural = "users", 27 | method = Method.Get, 28 | path = path, 29 | responseCode = 200, 30 | responseType = "[user]", 31 | parameters = Seq( 32 | Parameter( 33 | name = paramName, 34 | `type` = paramType, 35 | location = ParameterLocation.Query, 36 | required = false, 37 | default = default, 38 | minimum = minimum, 39 | maximum = maximum, 40 | ), 41 | ), 42 | ), 43 | ), 44 | ) 45 | } 46 | 47 | it("id") { 48 | linter.validate(buildService("id", "[string]", None, None, Some(100))) should be(Nil) 49 | 50 | linter.validate(buildService("id", "[long]", Some("5"), Some(1), None)) should be( 51 | Seq( 52 | "Resource users GET /users Parameter id: Type expected[[string]] but found[[long]]", 53 | "Resource users GET /users Parameter id: Default should not be specified", 54 | "Resource users GET /users Parameter id: Minimum should not be specified", 55 | "Resource users GET /users Parameter id: Maximum was not specified - should be 100", 56 | ), 57 | ) 58 | } 59 | 60 | it("id on versions endpoint") { 61 | linter.validate(buildService("id", "[long]", None, None, Some(100), path = "/users/versions")) should be(Nil) 62 | } 63 | 64 | it("limit") { 65 | linter.validate(buildService("limit", "long", Some("25"), Some(1), Some(100))) should be(Nil) 66 | 67 | linter.validate(buildService("limit", "string", Some("5"), Some(2), Some(10))) should be( 68 | Seq( 69 | "Resource users GET /users Parameter limit: Type expected[long] but found[string]", 70 | "Resource users GET /users Parameter limit: Default expected[25] but found[5]", 71 | "Resource users GET /users Parameter limit: Minimum expected[1] but found[2]", 72 | "Resource users GET /users Parameter limit: Maximum expected[100] but found[10]", 73 | ), 74 | ) 75 | 76 | linter.validate(buildService("limit", "long", None, None, None)) should be( 77 | Seq( 78 | "Resource users GET /users Parameter limit: Default was not specified - should be 25", 79 | "Resource users GET /users Parameter limit: Minimum was not specified - should be 1", 80 | "Resource users GET /users Parameter limit: Maximum was not specified - should be 100", 81 | ), 82 | ) 83 | } 84 | 85 | it("offset") { 86 | linter.validate(buildService("offset", "long", Some("0"), Some(0), None)) should be(Nil) 87 | 88 | linter.validate(buildService("offset", "string", Some("5"), Some(2), Some(10))) should be( 89 | Seq( 90 | "Resource users GET /users Parameter offset: Type expected[long] but found[string]", 91 | "Resource users GET /users Parameter offset: Default expected[0] but found[5]", 92 | "Resource users GET /users Parameter offset: Minimum expected[0] but found[2]", 93 | "Resource users GET /users Parameter offset: Maximum should not be specified", 94 | ), 95 | ) 96 | 97 | linter.validate(buildService("offset", "long", None, None, None)) should be( 98 | Seq( 99 | "Resource users GET /users Parameter offset: Default was not specified - should be 0", 100 | "Resource users GET /users Parameter offset: Minimum was not specified - should be 0", 101 | ), 102 | ) 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/CommonParametersHaveNoDescriptionsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class CommonParametersHaveNoDescriptionsSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.CommonParametersHaveNoDescriptions 10 | 11 | def buildService( 12 | paramName: String, 13 | paramDescription: Option[String] = None, 14 | ): Service = { 15 | Services.Base.copy( 16 | models = Seq( 17 | Services.buildSimpleModel("user"), 18 | ), 19 | resources = Seq( 20 | Services.buildSimpleResource( 21 | `type` = "user", 22 | plural = "users", 23 | method = Method.Get, 24 | path = "/users", 25 | responseCode = 200, 26 | responseType = "[organization]", 27 | parameters = Seq( 28 | Parameter( 29 | name = paramName, 30 | `type` = "[string]", 31 | location = ParameterLocation.Query, 32 | required = false, 33 | description = paramDescription, 34 | ), 35 | ), 36 | ), 37 | ), 38 | ) 39 | } 40 | 41 | it("Non common parameters can have descriptions") { 42 | linter.validate( 43 | buildService("foo", None), 44 | ) should be(Nil) 45 | 46 | linter.validate( 47 | buildService("foo", Some("bar")), 48 | ) should be(Nil) 49 | } 50 | 51 | it("Common parameters cannot have descriptions") { 52 | linters.CommonParametersHaveNoDescriptions.NamesWithNoDescriptions.foreach { name => 53 | linter.validate( 54 | buildService(name, Some("bar")), 55 | ) should be( 56 | Seq(s"Resource users GET /users Parameter $name: Must not have a description"), 57 | ) 58 | } 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/DuplicateMethodAndPathSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class DuplicateMethodAndPathSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.DuplicateMethodAndPath 10 | 11 | def buildService( 12 | methodsAndPaths: (Method, String)*, 13 | ): Service = { 14 | Services.Base.copy( 15 | models = Seq( 16 | Services.buildModel("user"), 17 | ), 18 | resources = Seq( 19 | Services.buildResource( 20 | "user", 21 | operations = methodsAndPaths.map { case (method, path) => 22 | Services.buildSimpleOperation( 23 | method = method, 24 | path = path, 25 | responseType = "user", 26 | ) 27 | }, 28 | ), 29 | ), 30 | ) 31 | } 32 | 33 | it("different paths") { 34 | linter.validate( 35 | buildService( 36 | (Method.Get, "/users"), 37 | (Method.Get, "/experiences"), 38 | ), 39 | ) 40 | } 41 | 42 | it("different methods") { 43 | linter.validate( 44 | buildService( 45 | (Method.Get, "/users"), 46 | (Method.Post, "/users"), 47 | ), 48 | ) should be(Nil) 49 | } 50 | 51 | it("validates duplicate method and path") { 52 | linter.validate( 53 | buildService( 54 | (Method.Get, "/users"), 55 | (Method.Get, "/users"), 56 | ), 57 | ) should be( 58 | Seq("1 or more operation paths is duplicated: GET /users"), 59 | ) 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/ErrorModelsV1Spec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | import play.api.libs.json.Json 7 | 8 | class ErrorModelsV1Spec extends AnyFunSpec with Matchers { 9 | 10 | private[this] val linter = linters.ErrorModelsV1 11 | 12 | private[this] val code = Services.buildField("code") 13 | private[this] val messages = Services.buildField("messages", "[string]", minimum = Some(1)) 14 | 15 | def buildService(fields: Seq[Field], attributes: Seq[Attribute] = Nil): Service = { 16 | Services.Base.copy( 17 | models = Seq( 18 | Services.buildModel( 19 | name = "test_error", 20 | fields = fields, 21 | attributes = attributes ++ Seq( 22 | Services.buildAttribute("linter", Json.obj("error_version" -> "1")), 23 | ), 24 | ), 25 | ), 26 | ) 27 | } 28 | 29 | it("standalone model fields must start with code, messages") { 30 | linter.validate(buildService(Seq(code, messages))) should be(Nil) 31 | 32 | linter.validate(buildService(Nil)) should be( 33 | Seq( 34 | "Model test_error: requires a field named 'code'", 35 | "Model test_error: requires a field named 'messages'", 36 | ), 37 | ) 38 | 39 | linter.validate(buildService(Seq(messages))) should be( 40 | Seq( 41 | "Model test_error: requires a field named 'code'", 42 | ), 43 | ) 44 | linter.validate(buildService(Seq(code))) should be( 45 | Seq( 46 | "Model test_error: requires a field named 'messages'", 47 | ), 48 | ) 49 | linter.validate(buildService(Seq(messages, code))) should be( 50 | Seq( 51 | "Model test_error: first field must be 'code'", 52 | "Model test_error: second field must be 'messages'", 53 | ), 54 | ) 55 | } 56 | 57 | it("standalone model validates type of 'code' field") { 58 | linter.validate(buildService(Seq(code.copy(`type` = "integer"), messages))) should be( 59 | Seq( 60 | "Model test_error Field[code]: type[integer] must refer to a valid enum", 61 | ), 62 | ) 63 | linter.validate(buildService(Seq(code, messages.copy(`type` = "integer")))) should be( 64 | Seq( 65 | "Model test_error Field[messages]: type must be '[string]'", 66 | ), 67 | ) 68 | } 69 | 70 | it("messages must have a minimum >= 1") { 71 | linter.validate(buildService(Seq(code, messages.copy(minimum = None)))) should be( 72 | Seq( 73 | "Model test_error Field[messages]: missing minimum", 74 | ), 75 | ) 76 | linter.validate(buildService(Seq(code, messages.copy(minimum = Some(0))))) should be( 77 | Seq( 78 | "Model test_error Field[messages]: minimum must be >= 1", 79 | ), 80 | ) 81 | } 82 | 83 | it("code can be an enum") { 84 | val baseService = buildService(Seq(code.copy(`type` = "error_code"), messages)) 85 | linter.validate(baseService) should be( 86 | Seq( 87 | "Model test_error Field[code]: type[error_code] must refer to a valid enum", 88 | ), 89 | ) 90 | 91 | val errorCode = Services.buildEnum("error_code") 92 | linter.validate(baseService.copy(enums = Seq(errorCode))) should be(Nil) 93 | } 94 | 95 | it("code can be imported enum") { 96 | val baseService = buildService(Seq(code.copy(`type` = "io.flow.error.v0.enums.generic_error"), messages)).copy( 97 | imports = Seq( 98 | Import( 99 | namespace = "io.flow.error.v0", 100 | uri = "https://app.apibuilder.io/flow/error/latest/service.json", 101 | organization = Organization("flow"), 102 | application = Application("error"), 103 | version = "0.0.1", 104 | enums = Seq("generic_error"), 105 | ), 106 | ), 107 | ) 108 | println(s"linter.validate(baseService): ${linter.validate(baseService)}") 109 | linter.validate(baseService) should be(Nil) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/ErrorModelsV2Spec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class ErrorModelsV2Spec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.ErrorModelsV2 10 | 11 | private[this] val codeEnum = Services.buildEnum( 12 | "checkout_error_code", 13 | values = Seq( 14 | Services.buildEnumValue("unknown"), 15 | ), 16 | ) 17 | private[this] val code = Services.buildField("code", "checkout_error_code") 18 | private[this] val message = Services.buildField("message") 19 | private[this] val error = Services.buildModel( 20 | "checkout_error", 21 | fields = Seq(code, message), 22 | ) 23 | private[this] val errors = Services.buildModel( 24 | "checkout_errors", 25 | fields = Seq( 26 | Services.buildField("errors", s"[${error.name}]"), 27 | ), 28 | ) 29 | 30 | def buildService(models: Seq[Model]): Service = { 31 | Services.Base.copy( 32 | enums = Seq(codeEnum), 33 | models = models, 34 | ) 35 | } 36 | 37 | it("errors must contain field named 'errors'") { 38 | linter.validate( 39 | buildService( 40 | Seq(Services.buildModel("test_errors")), 41 | ), 42 | ) should be( 43 | Seq( 44 | "Model test_errors: must contain a field named 'errors'", 45 | ), 46 | ) 47 | 48 | linter.validate(buildService(Seq(errors))) should be(Nil) 49 | } 50 | 51 | it("errors.errors must be an array") { 52 | linter.validate( 53 | buildService( 54 | Seq( 55 | Services.buildModel( 56 | "checkout_errors", 57 | fields = Seq( 58 | Services.buildField("errors", "string"), 59 | ), 60 | ), 61 | ), 62 | ), 63 | ) should be( 64 | Seq( 65 | "Model checkout_errors Field[errors]: type must be an array and not 'string'", 66 | ), 67 | ) 68 | } 69 | 70 | it("errors.errors base type must end in _error") { 71 | linter.validate( 72 | buildService( 73 | Seq( 74 | Services.buildModel( 75 | "checkout_errors", 76 | fields = Seq( 77 | Services.buildField("errors", "[foo]"), 78 | ), 79 | ), 80 | ), 81 | ), 82 | ) should be( 83 | Seq( 84 | "Model checkout_errors Field[errors]: type '[foo]' must end in '_error'", 85 | ), 86 | ) 87 | } 88 | 89 | it("errors must contain field named 'code' of type 'string'") { 90 | def build(fields: Seq[Field]) = { 91 | linter.validate( 92 | buildService( 93 | Seq(Services.buildModel("test_error", fields = fields)), 94 | ), 95 | ) 96 | } 97 | 98 | build( 99 | Seq( 100 | code, 101 | Services.buildField("bar", "string"), 102 | ), 103 | ) should be( 104 | Seq( 105 | "Model test_error: must contain a field named 'message'", 106 | ), 107 | ) 108 | 109 | build( 110 | Seq( 111 | code, 112 | Services.buildField("message", "integer"), 113 | ), 114 | ) should be( 115 | Seq( 116 | "Model test_error Field[message]: type must be 'string' and not 'integer'", 117 | ), 118 | ) 119 | 120 | linter.validate(buildService(Seq(error))) should be(Nil) 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/ErrorUnionModelsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | import play.api.libs.json.Json 7 | 8 | class ErrorUnionModelsSpec extends AnyFunSpec with Matchers { 9 | 10 | private[this] val linter = linters.ErrorModelsV1 11 | 12 | private[this] val messages = Services.buildField("messages", "[string]", minimum = Some(1)) 13 | 14 | def buildService( 15 | fields: Seq[Field], 16 | modelName: String = "no_inventory_reservation_error", 17 | unionName: String = "reservation_error", 18 | discriminator: Option[String] = Some("code"), 19 | ): Service = { 20 | Services.Base.copy( 21 | unions = Seq( 22 | Services.buildUnion( 23 | name = unionName, 24 | discriminator = discriminator, 25 | types = Seq( 26 | UnionType(`type` = modelName), 27 | ), 28 | ), 29 | ), 30 | models = Seq( 31 | Services.buildModel( 32 | name = modelName, 33 | fields = fields, 34 | attributes = Seq( 35 | Services.buildAttribute("linter", Json.obj("error_version" -> "1")), 36 | ), 37 | ), 38 | ), 39 | ) 40 | } 41 | 42 | it( 43 | "Must have a discriminator named 'code' and associated models must have a field in position 0 named 'messages' of type '[string]'", 44 | ) { 45 | linter.validate(buildService(Seq(messages))) should be(Nil) 46 | 47 | val itemNumbers = Services.buildField("item_numbers", "[string]") 48 | linter.validate(buildService(Seq(messages, itemNumbers))) should be(Nil) 49 | 50 | linter.validate(buildService(Seq(itemNumbers, messages))) should be( 51 | Seq( 52 | "Model no_inventory_reservation_error: second field must be 'messages'", 53 | ), 54 | ) 55 | 56 | linter.validate(buildService(Nil)) should be( 57 | Seq( 58 | "Model no_inventory_reservation_error: requires a field named 'messages'", 59 | ), 60 | ) 61 | } 62 | 63 | it("messages must have a minimum >= 1") { 64 | linter.validate(buildService(Seq(messages.copy(minimum = None)))) should be( 65 | Seq( 66 | "Model no_inventory_reservation_error Field[messages]: missing minimum", 67 | ), 68 | ) 69 | linter.validate(buildService(Seq(messages.copy(minimum = Some(0))))) should be( 70 | Seq( 71 | "Model no_inventory_reservation_error Field[messages]: minimum must be >= 1", 72 | ), 73 | ) 74 | } 75 | 76 | it("error union types MUST contain only models") { 77 | linter.validate( 78 | Services.Base.copy( 79 | unions = Seq( 80 | Services.buildUnion( 81 | name = "reservation_error", 82 | discriminator = Some("code"), 83 | types = Seq( 84 | UnionType(`type` = "string"), 85 | ), 86 | ), 87 | ), 88 | ), 89 | ) should be( 90 | Seq("Union reservation_error type string: Type must refer to a model to be part of an 'error' union type"), 91 | ) 92 | } 93 | 94 | it("All error union types must end in _error as well") { 95 | linter.validate(buildService(Seq(messages), modelName = "foo")) should be( 96 | Seq( 97 | "Union reservation_error type foo: Model name must end with '_error'", 98 | ), 99 | ) 100 | } 101 | 102 | it("error model that belongs to a non error union type should still get validated") { 103 | linter.validate(buildService(Seq(messages), unionName = "other")) should be( 104 | Seq( 105 | "Model no_inventory_reservation_error: requires a field named 'code'", 106 | ), 107 | ) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/EventUpsertedModelsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class EventUpsertedModelsSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.EventUpsertedModels 10 | 11 | private[this] def buildService( 12 | fields: Seq[String], 13 | attributes: Seq[Attribute] = Nil, 14 | ): Service = { 15 | Services.Base.copy( 16 | models = Seq( 17 | Services 18 | .buildModel( 19 | name = "org_upserted", 20 | fields = fields.map { case (name) => 21 | val typ = name match { 22 | case "timestamp" => "date-time-iso8601" 23 | case _ => "string" 24 | } 25 | Services.buildField(name = name, `type` = typ) 26 | }, 27 | ) 28 | .copy( 29 | attributes = attributes, 30 | ), 31 | ), 32 | ) 33 | } 34 | 35 | it("respects linter ignore hint") { 36 | linter.validate( 37 | buildService( 38 | fields = Seq("id"), 39 | attributes = Seq( 40 | Services.buildLinterIgnoreAttribute(Seq("event_model")), 41 | ), 42 | ), 43 | ) should be(Nil) 44 | } 45 | 46 | it("no-op w/out event_id") { 47 | linter.validate(buildService(Seq("id", "email"))) should be( 48 | Seq( 49 | "Model org_upserted: event_id must be the first field in event models", 50 | "Model org_upserted: timestamp field is required in event models", 51 | ), 52 | ) 53 | } 54 | 55 | it("fields") { 56 | linter.validate(buildService(Seq("event_id"))) should be( 57 | Seq( 58 | "Model org_upserted: timestamp field is required in event models", 59 | ), 60 | ) 61 | 62 | linter.validate(buildService(Seq("event_id", "foo", "timestamp"))) should be( 63 | Seq( 64 | "Model org_upserted: timestamp field must come after event_id in event models", 65 | ), 66 | ) 67 | 68 | linter.validate(buildService(Seq("event_id", "timestamp"))) should be(Nil) 69 | 70 | linter.validate(buildService(Seq("event_id", "timestamp", "number"))) should be( 71 | Seq( 72 | "Model org_upserted: organization field is required if event model has a field named number", 73 | ), 74 | ) 75 | 76 | linter.validate(buildService(Seq("event_id", "timestamp", "organization"))) should be(Nil) 77 | 78 | linter.validate(buildService(Seq("event_id", "timestamp", "foo", "organization"))) should be( 79 | Seq( 80 | "Model org_upserted: organization field must come after timestamp in event models", 81 | ), 82 | ) 83 | 84 | linter.validate(buildService(Seq("event_id", "timestamp", "id", "foo", "organization"))) should be( 85 | Seq( 86 | "Model org_upserted: organization field must come after id in event models", 87 | ), 88 | ) 89 | 90 | linter.validate(buildService(Seq("event_id", "timestamp", "id", "organization", "number"))) should be(Nil) 91 | linter.validate(buildService(Seq("event_id", "timestamp", "id", "organization", "number", "foo"))) should be(Nil) 92 | 93 | linter.validate(buildService(Seq("event_id", "timestamp", "id", "organization", "foo", "number"))) should be( 94 | Seq( 95 | "Model org_upserted: number field must come after organization in event models", 96 | ), 97 | ) 98 | 99 | linter.validate(buildService(Seq("event_id", "timestamp", "organization", "number"))) should be(Nil) 100 | linter.validate(buildService(Seq("event_id", "timestamp", "organization", "number", "foo"))) should be(Nil) 101 | 102 | linter.validate(buildService(Seq("event_id", "timestamp", "organization", "foo", "number"))) should be( 103 | Seq( 104 | "Model org_upserted: number field must come after organization in event models", 105 | ), 106 | ) 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/ExpandableUnionsAreConsistentSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class ExpandableUnionsAreConsistentSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.ExpandableUnionsAreConsistent 10 | 11 | def buildService(types: Seq[String]): Service = { 12 | Services.Base.copy( 13 | unions = Seq( 14 | Services.buildUnion( 15 | name = "expandable_user", 16 | discriminator = Some("discriminator"), 17 | types = types.map { t => 18 | Services.buildUnionType(t) 19 | }, 20 | ), 21 | ), 22 | ) 23 | } 24 | 25 | it("with no types") { 26 | linter.validate(buildService(Nil)) should be( 27 | Seq("Union expandable_user: must contain the following types: user, user_reference"), 28 | ) 29 | } 30 | 31 | it("with single types") { 32 | linter.validate(buildService(Seq("user_reference"))) should be( 33 | Seq("Union expandable_user: must contain a type named 'user'"), 34 | ) 35 | 36 | linter.validate(buildService(Seq("user"))) should be( 37 | Seq("Union expandable_user: must contain a type named 'user_reference'"), 38 | ) 39 | } 40 | 41 | it("with valid types") { 42 | linter.validate(buildService(Seq("user", "user_reference"))) should be(Nil) 43 | } 44 | 45 | it("with valid types in invalid order") { 46 | linter.validate(buildService(Seq("user_reference", "user"))) should be( 47 | Seq("Union expandable_user: types must be in the following order: user, user_reference"), 48 | ) 49 | } 50 | 51 | it("with invalid types") { 52 | linter.validate(buildService(Seq("foo", "bar"))) should be( 53 | Seq("Union expandable_user: must contain the following types: user, user_reference"), 54 | ) 55 | } 56 | 57 | it("with valid types when the type itself is a union") { 58 | val s = Services.Base.copy( 59 | unions = Seq( 60 | Services.buildUnion( 61 | name = "expandable_payment", 62 | discriminator = Some("discriminator"), 63 | types = Seq( 64 | Services.buildUnionType("payment_paypal"), 65 | Services.buildUnionType("payment_reference"), 66 | ), 67 | ), 68 | Services.buildUnion( 69 | name = "payment", 70 | discriminator = Some("discriminator"), 71 | types = Seq( 72 | Services.buildUnionType("payment_paypal"), 73 | ), 74 | ), 75 | ), 76 | ) 77 | 78 | linter.validate(s) should be(Nil) 79 | } 80 | 81 | it("with invalid types when the type itself is a union") { 82 | val s = Services.Base.copy( 83 | unions = Seq( 84 | Services.buildUnion( 85 | name = "expandable_payment", 86 | discriminator = Some("discriminator"), 87 | types = Seq( 88 | Services.buildUnionType("other"), 89 | Services.buildUnionType("payment_reference"), 90 | ), 91 | ), 92 | Services.buildUnion( 93 | name = "payment", 94 | discriminator = Some("discriminator"), 95 | types = Seq( 96 | Services.buildUnionType("payment_paypal"), 97 | ), 98 | ), 99 | ), 100 | ) 101 | 102 | linter.validate(s) should be( 103 | Seq("Union expandable_payment: must contain a type named 'payment_paypal'"), 104 | ) 105 | } 106 | 107 | it("allows additional types") { 108 | val s = Services.Base.copy( 109 | unions = Seq( 110 | Services.buildUnion( 111 | name = "expandable_card", 112 | discriminator = Some("discriminator"), 113 | types = Seq( 114 | Services.buildUnionType("card"), 115 | Services.buildUnionType("card_reference"), 116 | Services.buildUnionType("card_summary"), 117 | ), 118 | ), 119 | ), 120 | ) 121 | 122 | linter.validate(s) should be(Nil) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/GetByIdIsExpandableSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class GetByIdIsExpandableSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.GetByIdIsExpandable 10 | 11 | def buildService( 12 | responseType: String = "[expandable_organization]", 13 | params: Seq[Parameter] = Nil, 14 | ): Service = { 15 | Services.Base.copy( 16 | models = Seq( 17 | Services.buildModel( 18 | "organization", 19 | fields = Seq( 20 | Services.buildField("id"), 21 | Services.buildField("user", "io.flow.common.v0.models.expandable_user"), 22 | ), 23 | ), 24 | ), 25 | resources = Seq( 26 | Resource( 27 | `type` = "organization", 28 | plural = "organizations", 29 | operations = Seq( 30 | Operation( 31 | method = Method.Get, 32 | path = "/organizations", 33 | parameters = params, 34 | responses = Seq( 35 | Services.buildResponse(`type` = responseType), 36 | ), 37 | ), 38 | ), 39 | ), 40 | ), 41 | ) 42 | } 43 | 44 | it("resource that does not return an expansion") { 45 | linter.validate(buildService(responseType = "[organization]")) should be(Nil) 46 | } 47 | 48 | it("resource that returns an expansion") { 49 | linter.validate(buildService(responseType = "[expandable_organization]")) should be( 50 | Seq("Resource organizations GET /organizations: Missing parameter named expand"), 51 | ) 52 | 53 | linter.validate(buildService(responseType = "[io.flow.common.v0.models.expandable_organization]")) should be( 54 | Seq("Resource organizations GET /organizations: Missing parameter named expand"), 55 | ) 56 | } 57 | 58 | it("resource that returns an expansion with an expand param") { 59 | linter.validate( 60 | buildService( 61 | params = Seq( 62 | Parameter( 63 | name = "expand", 64 | `type` = "[string]", 65 | location = ParameterLocation.Query, 66 | required = false, 67 | ), 68 | ), 69 | ), 70 | ) should be(Nil) 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/GetQuerySpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class GetQuerySpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.Get 10 | 11 | val model = Services.buildSimpleModel( 12 | "organization", 13 | fields = Seq("id", "name"), 14 | ) 15 | 16 | val idParameter = Parameter( 17 | name = "q", 18 | `type` = "string", 19 | location = ParameterLocation.Query, 20 | required = false, 21 | ) 22 | 23 | def buildResourceWithSearch(params: Seq[Parameter]) = { 24 | Services.Base.copy( 25 | resources = Seq( 26 | Resource( 27 | `type` = "organization", 28 | plural = "organizations", 29 | operations = Seq( 30 | Operation( 31 | method = Method.Get, 32 | path = "/organizations", 33 | parameters = params, 34 | responses = Seq( 35 | Services.buildResponse(`type` = "[organization]"), 36 | ), 37 | ), 38 | ), 39 | ), 40 | ), 41 | ) 42 | } 43 | 44 | it("GET / validates 'q' must be optional") { 45 | linter.validate( 46 | buildResourceWithSearch( 47 | Seq( 48 | Parameter( 49 | name = "q", 50 | `type` = "string", 51 | location = ParameterLocation.Query, 52 | required = true, 53 | ), 54 | ), 55 | ), 56 | ) should be( 57 | Seq( 58 | "Resource organizations GET /organizations: Parameter[q] must be optional", 59 | ), 60 | ) 61 | } 62 | 63 | it("GET / with valid resources") { 64 | linter.validate( 65 | buildResourceWithSearch( 66 | Seq( 67 | Parameter( 68 | name = "q", 69 | `type` = "string", 70 | location = ParameterLocation.Query, 71 | required = false, 72 | ), 73 | ), 74 | ), 75 | ) should be(Nil) 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/HelpersSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.linters 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class HelpersSpec extends AnyFunSpec with Matchers { 7 | 8 | case object TestHelper extends Helpers 9 | 10 | it("isCountry") { 11 | TestHelper.isCountry("country") should be(true) 12 | TestHelper.isCountry("origin") should be(true) 13 | TestHelper.isCountry("id") should be(false) 14 | } 15 | 16 | it("isCurrency") { 17 | TestHelper.isCurrency("currency") should be(true) 18 | TestHelper.isCurrency("id") should be(false) 19 | } 20 | 21 | it("isLanguage") { 22 | TestHelper.isLanguage("language") should be(true) 23 | TestHelper.isLanguage("id") should be(false) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/InclusiveTerminologyLinterSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class InclusiveTerminologyLinterSpec extends AnyFunSpec with Matchers { 8 | import Services._ 9 | 10 | private[this] val linter = linters.InclusiveTerminologyLinter 11 | 12 | def test( 13 | headers: Seq[Header] = Nil, 14 | enums: Seq[Enum] = Nil, 15 | interfaces: Seq[Interface] = Nil, 16 | unions: Seq[Union] = Nil, 17 | models: Seq[Model] = Nil, 18 | resources: Seq[Resource] = Nil, 19 | ): Seq[String] = { 20 | linter.validate( 21 | Base.copy( 22 | headers = headers, 23 | enums = enums, 24 | interfaces = interfaces, 25 | unions = unions, 26 | models = models, 27 | resources = resources, 28 | ), 29 | ) 30 | } 31 | 32 | it("interface") { 33 | test(interfaces = Seq(buildInterface(name = "blacklist"))) should be( 34 | Seq("Interface blacklist: The term 'blacklist' must be replaced by 'denylist'"), 35 | ) 36 | } 37 | 38 | it("interface.field") { 39 | test(interfaces = Seq(buildInterface(name = "example", fields = Seq(buildField("blacklist"))))) should be( 40 | Seq("Interface example Field[blacklist]: The term 'blacklist' must be replaced by 'denylist'"), 41 | ) 42 | } 43 | 44 | it("model") { 45 | test(models = Seq(buildModel(name = "blacklist"))) should be( 46 | Seq("Model blacklist: The term 'blacklist' must be replaced by 'denylist'"), 47 | ) 48 | } 49 | 50 | it("model.field") { 51 | test(models = Seq(buildModel(name = "example", fields = Seq(buildField("blacklist"))))) should be( 52 | Seq("Model example Field[blacklist]: The term 'blacklist' must be replaced by 'denylist'"), 53 | ) 54 | } 55 | 56 | it("header") { 57 | test(headers = Seq(buildHeader(name = "blacklist"))) should be( 58 | Seq("Header blacklist: The term 'blacklist' must be replaced by 'denylist'"), 59 | ) 60 | } 61 | 62 | it("enum") { 63 | test(enums = 64 | Seq( 65 | buildEnum(name = "blacklist"), 66 | ), 67 | ) should be( 68 | Seq("Enum blacklist: The term 'blacklist' must be replaced by 'denylist'"), 69 | ) 70 | } 71 | 72 | it("enum.value") { 73 | test(enums = 74 | Seq( 75 | buildEnum(name = "example", values = Seq(buildEnumValue("blacklist"))), 76 | ), 77 | ) should be( 78 | Seq("Enum example value blacklist: The term 'blacklist' must be replaced by 'denylist'"), 79 | ) 80 | } 81 | 82 | it("union") { 83 | test(unions = 84 | Seq( 85 | buildUnion(name = "blacklist"), 86 | ), 87 | ) should be( 88 | Seq("Union blacklist: The term 'blacklist' must be replaced by 'denylist'"), 89 | ) 90 | } 91 | 92 | it("union.discriminator") { 93 | test(unions = 94 | Seq( 95 | buildUnion(name = "example", discriminator = Some("blacklist")), 96 | ), 97 | ) should be( 98 | Seq("Union example: discriminator: The term 'blacklist' must be replaced by 'denylist'"), 99 | ) 100 | } 101 | 102 | it("union.type") { 103 | test(unions = 104 | Seq( 105 | buildUnion(name = "example", types = Seq(buildUnionType("blacklist"))), 106 | ), 107 | ) should be( 108 | Seq("Union example type blacklist: The term 'blacklist' must be replaced by 'denylist'"), 109 | ) 110 | } 111 | 112 | it("union.type.discriminatorValue") { 113 | test(unions = 114 | Seq( 115 | buildUnion( 116 | name = "example", 117 | types = Seq(buildUnionType(`type` = "type1", discriminatorValue = Some("blacklist"))), 118 | ), 119 | ), 120 | ) should be( 121 | Seq("Union example type type1: discriminator value: The term 'blacklist' must be replaced by 'denylist'"), 122 | ) 123 | } 124 | 125 | it("parameter.name") { 126 | test(resources = 127 | Seq( 128 | buildResource( 129 | "user", 130 | operations = Seq( 131 | buildSimpleOperation( 132 | parameters = Seq( 133 | buildParameter("blacklist"), 134 | ), 135 | ), 136 | ), 137 | ), 138 | ), 139 | ) should be( 140 | Seq("Resource users GET / Parameter blacklist: The term 'blacklist' must be replaced by 'denylist'"), 141 | ) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/LintSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.flow.build.BuildType 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class LintSpec extends AnyFunSpec with Matchers { 8 | 9 | val Dir = new java.io.File("src/main/scala/io/flow/lint/linters") 10 | 11 | it("All lists all linters") { 12 | // avoid runtime reflection but still fail the build if a new 13 | // linter is introduced that is not added to the constant All 14 | // val Pattern = """case object (.+)\s+extends\s+Linter""".r 15 | val Pattern = """.*case\s*object (.+) extends Linter.*""".r 16 | 17 | val all = BuildType.all.flatMap(Lint.forBuildType).map(_.toString).distinct.toSet 18 | 19 | for (file <- Dir.listFiles if file.getName.endsWith(".scala") && !file.getName.endsWith("Helpers.scala")) { 20 | var found = false 21 | scala.io.Source.fromFile(file).getLines().foreach { 22 | case Pattern(className) => { 23 | found = true 24 | if (!all.contains(className)) { 25 | fail(s"Lint.All is missing linter[$className] - it contains: " + all.toSeq.sorted.mkString(", ")) 26 | } 27 | } 28 | case _ => {} 29 | } 30 | 31 | if (!found) { 32 | fail(s"Linter definition not recognized in file[$file]") 33 | } 34 | } 35 | } 36 | 37 | it("Lint.All is alphabetized") { 38 | BuildType.all.foreach { bt => 39 | Lint.forBuildType(bt).map(_.toString).sorted should be( 40 | Lint.forBuildType(bt).map(_.toString), 41 | ) 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/LowerCasePathsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class LowerCasePathsSpec extends AnyFunSpec with Matchers { 7 | 8 | private[this] val linter = linters.LowerCasePaths 9 | 10 | it("lower case paths are good") { 11 | linter.validate( 12 | Services.buildServiceByPath("/organizations/tiers/:tier_id"), 13 | ) should be(Nil) 14 | } 15 | 16 | it("Flags upper case paths") { 17 | linter.validate( 18 | Services.buildServiceByPath("/organizations/tiers/:tierId"), 19 | ) should be( 20 | Seq( 21 | "Resource organizations GET /organizations/tiers/:tierId: Path must be all lower case", 22 | ), 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/MappingModelsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class MappingModelsSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.MappingModels 10 | 11 | private[this] val id = Services.buildField("id", "string") 12 | private[this] val experienceReference = Services.buildField("experience", "experience_reference") 13 | private[this] val priceBookReference = Services.buildField("price_book", "price_book_reference") 14 | private[this] val position = Services.buildField("position", "long") 15 | 16 | def buildService( 17 | name: String = "experience_price_book_mapping", 18 | fields: Seq[Field], 19 | ): Service = { 20 | Services.Base.copy( 21 | models = Seq( 22 | Services.buildModel( 23 | name = name, 24 | fields = fields, 25 | ), 26 | ), 27 | ) 28 | } 29 | 30 | it("validates model name") { 31 | linter.validate( 32 | buildService( 33 | name = "experience_price_book_mapping", 34 | fields = Seq(id, experienceReference, priceBookReference), 35 | ), 36 | ) should be(Nil) 37 | 38 | linter.validate( 39 | buildService( 40 | name = "price_book_experience_mapping", 41 | fields = Seq(id, experienceReference, priceBookReference), 42 | ), 43 | ) should be( 44 | Seq("Model 'price_book_experience_mapping' must be named 'experience_price_book_mapping'"), 45 | ) 46 | } 47 | 48 | it("requires at least 3 fields") { 49 | linter.validate( 50 | buildService( 51 | name = "experience_price_book_mapping", 52 | fields = Seq(id), 53 | ), 54 | ) should be( 55 | Seq("Model experience_price_book_mapping: Mapping models must have at least 3 fields"), 56 | ) 57 | 58 | linter.validate( 59 | buildService( 60 | name = "experience_price_book_mapping", 61 | fields = Seq(id, experienceReference), 62 | ), 63 | ) should be( 64 | Seq("Model experience_price_book_mapping: Mapping models must have at least 3 fields"), 65 | ) 66 | } 67 | 68 | it("allows additional fields") { 69 | linter.validate( 70 | buildService( 71 | name = "experience_price_book_mapping", 72 | fields = Seq(id, experienceReference, priceBookReference, position), 73 | ), 74 | ) should be(Nil) 75 | } 76 | 77 | it("validates types") { 78 | linter.validate( 79 | buildService( 80 | name = "experience_price_book_mapping", 81 | fields = Seq(id, position, experienceReference, priceBookReference), 82 | ), 83 | ) should be( 84 | Seq("Field 'position' type must be 'position_reference'"), 85 | ) 86 | } 87 | 88 | it("validates names") { 89 | val other = Services.buildField("foo", "order_reference") 90 | val fullyQualified = Services.buildField("price_book", "io.flow.price.v0.models.price_book_reference") 91 | 92 | linter.validate( 93 | buildService( 94 | name = "experience_price_book_mapping", 95 | fields = Seq(id, other, experienceReference, priceBookReference), 96 | ), 97 | ) should be( 98 | Seq("Field 2 'foo' must be named 'order'"), 99 | ) 100 | 101 | linter.validate( 102 | buildService( 103 | name = "experience_price_book_mapping", 104 | fields = Seq(id, experienceReference, fullyQualified), 105 | ), 106 | ) should be( 107 | Nil, 108 | ) 109 | } 110 | 111 | it("validates field types are proper references") { 112 | linter.validate( 113 | buildService( 114 | name = "experience_price_book_mapping", 115 | fields = Seq(id, experienceReference, priceBookReference), 116 | ), 117 | ) should be(Nil) 118 | 119 | linter.validate( 120 | buildService( 121 | name = "price_book_experience_mapping", 122 | fields = Seq( 123 | id, 124 | Services.buildField("experience", "string"), 125 | priceBookReference, 126 | ), 127 | ), 128 | ) should be( 129 | Seq("Field 'experience' type must be 'experience_reference'"), 130 | ) 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/ModelsWithOrganizationFieldSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class ModelsWithOrganizationFieldSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.ModelsWithOrganizationField 10 | 11 | def buildService(fields: Seq[String]): Service = { 12 | Services.Base.copy( 13 | models = Seq( 14 | Services.buildSimpleModel("user", fields), 15 | ), 16 | ) 17 | } 18 | 19 | it("no-op w/out organization field") { 20 | linter.validate(buildService(Seq("id", "email"))) should be(Nil) 21 | } 22 | 23 | it("no-op if organization field is optional") { 24 | linter.validate( 25 | Services.Base.copy( 26 | models = Seq( 27 | Services.buildModel( 28 | name = "user", 29 | fields = Seq( 30 | Services.buildField(name = "foobar"), 31 | Services.buildField(name = "organization", required = false), 32 | ), 33 | ), 34 | ), 35 | ), 36 | ) should be(Nil) 37 | } 38 | 39 | it("w/ organization but no id fields") { 40 | linter.validate(buildService(Seq("other", "organization"))) should be( 41 | Seq( 42 | "Model user: Field[organization] must be in position[0] and not[1]", 43 | ), 44 | ) 45 | } 46 | 47 | it("w/ id and organization fields") { 48 | linter.validate(buildService(Seq("id", "other", "organization"))) should be( 49 | Seq( 50 | "Model user: Field[organization] must be in position[1] and not[2]", 51 | ), 52 | ) 53 | } 54 | 55 | it("w/ id, timestamp, type and organization fields") { 56 | linter.validate(buildService(Seq("id", "timestamp", "type", "other", "organization"))) should be( 57 | Seq( 58 | "Model user: Field[organization] must be in position[3] and not[4]", 59 | ), 60 | ) 61 | } 62 | 63 | it("w/ event_id, timestamp, id fields") { 64 | linter.validate(buildService(Seq("event_id", "timestamp", "id", "other", "organization"))) should be( 65 | Seq( 66 | "Model user: Field[organization] must be in position[3] and not[4]", 67 | ), 68 | ) 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/PathsDoNotHaveTrailingSlashSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class PathsDoNotHaveTrailingSlash extends AnyFunSpec with Matchers { 7 | 8 | private[this] val linter = linters.PathsDoNotHaveTrailingSlash 9 | 10 | it("good path") { 11 | linter.validate( 12 | Services.buildServiceByPath("/:organization"), 13 | ) should be(Nil) 14 | linter.validate( 15 | Services.buildServiceByPath("/:organization/users"), 16 | ) should be(Nil) 17 | } 18 | 19 | it("bad path") { 20 | linter.validate( 21 | Services.buildServiceByPath("/:organization/"), 22 | ) should be( 23 | Seq( 24 | "Resource organizations GET /:organization/: Path cannot end with '/'", 25 | ), 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/ProxyQueryParametersSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class ProxyQueryParametersSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.ProxyQueryParameters 10 | 11 | def buildService( 12 | paramName: String, 13 | ): Service = { 14 | Services.Base.copy( 15 | models = Seq( 16 | Services.buildSimpleModel("user"), 17 | ), 18 | resources = Seq( 19 | Services.buildSimpleResource( 20 | `type` = "user", 21 | plural = "users", 22 | method = Method.Get, 23 | path = "/users", 24 | responseCode = 200, 25 | responseType = "[user]", 26 | parameters = Seq( 27 | Parameter( 28 | name = paramName, 29 | `type` = "[string]", 30 | location = ParameterLocation.Query, 31 | required = false, 32 | description = None, 33 | ), 34 | ), 35 | ), 36 | ), 37 | ) 38 | } 39 | 40 | it("allows non reserved words") { 41 | linter.validate( 42 | buildService("id"), 43 | ) should be(Nil) 44 | } 45 | 46 | it("validates reserved words") { 47 | Seq("callback", "envelope", "method").foreach { word => 48 | linter.validate( 49 | buildService(word), 50 | ) should be( 51 | Seq( 52 | s"Resource users GET /users Parameter $word: name is reserved for use only in https://github.com/flowvault/proxy", 53 | ), 54 | ) 55 | } 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/PublishedEventModelsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models.{Attribute, Service} 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class PublishedEventModelsSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.PublishedEventModels 10 | 11 | private[this] def buildService( 12 | fields: Map[String, String], 13 | attributes: Seq[Attribute] = Nil, 14 | ): Service = { 15 | Services.Base.copy( 16 | models = Seq( 17 | Services 18 | .buildModel( 19 | name = "organization_rates_published", 20 | fields = fields.map { case (name, typ) => Services.buildField(name = name, `type` = typ) }.toList, 21 | ) 22 | .copy( 23 | attributes = attributes, 24 | ), 25 | ), 26 | ) 27 | } 28 | 29 | it("respects linter ignore hint") { 30 | linter.validate( 31 | buildService( 32 | Map( 33 | "id" -> "string", 34 | "email" -> "string", 35 | ), 36 | attributes = Seq( 37 | Services.buildLinterIgnoreAttribute(Seq("published_event_model")), 38 | ), 39 | ), 40 | ) should be(Nil) 41 | } 42 | 43 | it("no-op w/ invalid fields") { 44 | linter.validate( 45 | buildService( 46 | Map( 47 | "id" -> "string", 48 | "email" -> "string", 49 | ), 50 | ), 51 | ) should be( 52 | Seq( 53 | "Model organization_rates_published: Published event models must contain exactly four fields: event_id, timestamp, organization, data. Your model was defined as: id, email", 54 | ), 55 | ) 56 | } 57 | 58 | it("w/ valid field types") { 59 | linter.validate( 60 | buildService( 61 | Map( 62 | "event_id" -> "string", 63 | "timestamp" -> "date-time-iso8601", 64 | "organization" -> "string", 65 | "data" -> "organization_rates_data", 66 | ), 67 | ), 68 | ) should be(Nil) 69 | } 70 | 71 | it("w/ invalid field types") { 72 | linter.validate( 73 | buildService( 74 | Map( 75 | "event_id" -> "long", 76 | "timestamp" -> "boolean", 77 | "organization" -> "integer", 78 | "data" -> "string", 79 | ), 80 | ), 81 | ) should be( 82 | Seq( 83 | "Model organization_rates_published Field[event_id]: type must be 'string' and not long", 84 | "Model organization_rates_published Field[timestamp]: type must be 'date-time-iso8601' and not boolean", 85 | "Model organization_rates_published Field[organization]: type must be 'string' and not integer", 86 | "Model organization_rates_published Field[data]: type must be 'organization_rates_data' and not string", 87 | ), 88 | ) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/SortAttributeSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | import play.api.libs.json.Json 7 | 8 | class SortAttributeSpec extends AnyFunSpec with Matchers { 9 | 10 | private[this] val linter = linters.SortAttribute 11 | 12 | val sortParameter = Parameter( 13 | name = "sort", 14 | `type` = "string", 15 | location = ParameterLocation.Query, 16 | required = false, 17 | default = None, 18 | ) 19 | 20 | val sortAttribute = Attribute( 21 | name = "sort", 22 | Json.obj(), 23 | ) 24 | 25 | val otherAttribute = Attribute( 26 | name = "other", 27 | Json.obj(), 28 | ) 29 | 30 | def buildService(parameter: Parameter, attribute: Attribute): Service = { 31 | Services.Base.copy( 32 | resources = Seq( 33 | Services.buildSimpleResource( 34 | `type` = "organization", 35 | plural = "organizations", 36 | method = Method.Get, 37 | path = "/organization", 38 | responseCode = 200, 39 | responseType = "[organization]", 40 | parameters = Seq(parameter), 41 | attributes = Seq(attribute), 42 | ), 43 | ), 44 | ) 45 | } 46 | 47 | it("should be empty when sort attribute exists") { 48 | linter.validate( 49 | buildService(sortParameter, sortAttribute), 50 | ) should be(Nil) 51 | } 52 | 53 | it("should have errors when sort attribute is missing") { 54 | linter.validate( 55 | buildService(sortParameter, otherAttribute), 56 | ) should be(Seq("Resource organizations GET /organization: Missing attribute named sort")) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/SortParameterDefaultSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class SortParameterDefaultSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.SortParameterDefault 10 | 11 | def buildService(model: Model, param: Parameter, path: String = "/organizations"): Service = { 12 | Services.Base.copy( 13 | models = Seq(model), 14 | resources = Seq( 15 | Services.buildSimpleResource( 16 | `type` = "organization", 17 | plural = "organizations", 18 | method = Method.Get, 19 | path = path, 20 | responseCode = 200, 21 | responseType = "[organization]", 22 | parameters = Seq(param), 23 | ), 24 | ), 25 | ) 26 | } 27 | 28 | val sortParameter = Parameter( 29 | name = "sort", 30 | `type` = "string", 31 | location = ParameterLocation.Query, 32 | required = false, 33 | default = None, 34 | ) 35 | 36 | it("No-op if no sort parameter") { 37 | linter.validate( 38 | buildService( 39 | Services.buildSimpleModel("organization", fields = Nil), 40 | Parameter( 41 | name = "id", 42 | `type` = "string", 43 | location = ParameterLocation.Query, 44 | required = false, 45 | default = None, 46 | ), 47 | ), 48 | ) should be(Nil) 49 | } 50 | 51 | it("Requires a default is present") { 52 | linter.validate( 53 | buildService( 54 | Services.buildSimpleModel("organization", fields = Nil), 55 | Parameter( 56 | name = "sort", 57 | `type` = "string", 58 | location = ParameterLocation.Query, 59 | required = false, 60 | default = None, 61 | ), 62 | ), 63 | ) should be( 64 | Seq("Resource organizations GET /organizations: Parameter sort requires a default"), 65 | ) 66 | } 67 | 68 | it("Requires '-created_at' if no name field") { 69 | linter.validate( 70 | buildService( 71 | Services.buildSimpleModel("organization", fields = Nil), 72 | Parameter( 73 | name = "sort", 74 | `type` = "string", 75 | location = ParameterLocation.Query, 76 | required = false, 77 | default = Some("foo"), 78 | ), 79 | ), 80 | ) should be( 81 | Seq("Resource organizations GET /organizations: Parameter sort default expected to be[-created_at] and not[foo]"), 82 | ) 83 | } 84 | 85 | it("Allows either default for imported types") { 86 | linter.validate( 87 | buildService( 88 | Services.buildSimpleModel("io.flow.common.v0.models.organization", fields = Nil), 89 | Parameter( 90 | name = "sort", 91 | `type` = "string", 92 | location = ParameterLocation.Query, 93 | required = false, 94 | default = Some("fo"), 95 | ), 96 | ), 97 | ) should be( 98 | Seq( 99 | "Resource organizations GET /organizations: Parameter sort default expected to be[-created_at or name] and not[fo]", 100 | ), 101 | ) 102 | 103 | Seq("-created_at", "name").foreach { sort => 104 | linter.validate( 105 | buildService( 106 | Services.buildSimpleModel("io.flow.common.v0.models.organization", fields = Nil), 107 | Parameter( 108 | name = "sort", 109 | `type` = "string", 110 | location = ParameterLocation.Query, 111 | required = false, 112 | default = Some(sort), 113 | ), 114 | ), 115 | ) should be(Nil) 116 | } 117 | } 118 | 119 | it("Requires 'name' if name field") { 120 | linter.validate( 121 | buildService( 122 | Services.buildSimpleModel("organization", fields = Seq("name")), 123 | Parameter( 124 | name = "sort", 125 | `type` = "string", 126 | location = ParameterLocation.Query, 127 | required = false, 128 | default = Some("foo"), 129 | ), 130 | ), 131 | ) should be( 132 | Seq("Resource organizations GET /organizations: Parameter sort default expected to be[name] and not[foo]"), 133 | ) 134 | } 135 | 136 | it("Requires 'journal_timestamp' if path ends in /versions") { 137 | linter.validate( 138 | buildService( 139 | Services.buildSimpleModel("organization", fields = Seq("name")), 140 | Parameter( 141 | name = "sort", 142 | `type` = "string", 143 | location = ParameterLocation.Query, 144 | required = false, 145 | default = Some("name"), 146 | ), 147 | path = "/organizations/versions", 148 | ), 149 | ) should be( 150 | Seq( 151 | "Resource organizations GET /organizations/versions: Parameter sort default expected to be[journal_timestamp] and not[name]", 152 | ), 153 | ) 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/UnionTypesHaveCommonDiscriminatorSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class UnionTypesHaveCommonDiscriminatorSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.UnionTypesHaveCommonDiscriminator 10 | 11 | def buildService( 12 | typeName: String, 13 | discriminator: Option[String], 14 | ): Service = { 15 | Services.Base.copy( 16 | unions = Seq( 17 | Services.buildUnion( 18 | name = typeName, 19 | discriminator = discriminator, 20 | types = Seq( 21 | Services.buildUnionType("string"), 22 | Services.buildUnionType("uuid"), 23 | ), 24 | ), 25 | ), 26 | ) 27 | } 28 | 29 | it("with no discriminator") { 30 | linter.validate(buildService("expandable_user", None)) should be( 31 | Seq("Union expandable_user: Must have a discriminator with value one of ('discriminator', 'type', 'code')"), 32 | ) 33 | } 34 | 35 | it("with invalid discriminator") { 36 | linter.validate(buildService("expandable_user", Some("foo"))) should be( 37 | Seq("Union expandable_user: Discriminator must have value one of ('discriminator', 'type', 'code') and not 'foo'"), 38 | ) 39 | } 40 | 41 | it("with valid discriminator") { 42 | linter.validate(buildService("expandable_user", Some("discriminator"))) should be(Nil) 43 | } 44 | 45 | it("with valid discriminator - type") { 46 | linter.validate(buildService("expandable_user", Some("type"))) should be(Nil) 47 | } 48 | 49 | it("with valid discriminator for localized_price hack") { 50 | linter.validate(buildService("localized_price", Some("key"))) should be(Nil) 51 | } 52 | 53 | it("union types that end in _error must have a discriminator named 'code'") { 54 | linter.validate(buildService("user_error", None)) should be( 55 | Seq("Union user_error: Must have a discriminator with value one of ('code')"), 56 | ) 57 | } 58 | 59 | it("union types that end in _error with invalid discriminator") { 60 | linter.validate(buildService("user_error", Some("foo"))) should be( 61 | Seq("Union user_error: Discriminator must have value one of ('code') and not 'foo'"), 62 | ) 63 | } 64 | 65 | it("union types that end in _error with valid discriminator") { 66 | linter.validate(buildService("user_error", Some("code"))) should be(Nil) 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/UpsertedDeletedEventModelsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class UpsertedDeletedEventModelsSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.UpsertedDeletedEventModels 10 | 11 | def buildService(modelName: String, fieldName: String, fieldType: String): Service = { 12 | Services.Base.copy( 13 | models = Seq( 14 | Services.buildModel( 15 | name = modelName, 16 | Seq(Services.buildField(name = fieldName, `type` = fieldType)), 17 | ), 18 | ), 19 | ) 20 | } 21 | 22 | it("with valid names") { 23 | linter.validate(buildService("example_upserted", "example", "example")) should be(Nil) 24 | 25 | linter.validate(buildService("example_upserted", "foo", "example")) should be( 26 | Seq("Model example_upserted: Event must contain a field whose name and type contain example"), 27 | ) 28 | } 29 | 30 | it("with partial names") { 31 | linter.validate(buildService("card_authorization_upserted", "card_authorization", "card_authorization")) should be( 32 | Nil, 33 | ) 34 | linter.validate(buildService("card_authorization_upserted", "card", "card_authorization")) should be(Nil) 35 | linter.validate(buildService("card_authorization_upserted", "authorization", "card_authorization")) should be(Nil) 36 | linter.validate(buildService("card_authorization_upserted", "foo", "card_authorization")) should be( 37 | Seq( 38 | "Model card_authorization_upserted: Event must contain a field whose name and type contain card or authorization", 39 | ), 40 | ) 41 | linter.validate(buildService("card_authorization_upserted", "card_authorization", "foo")) should be( 42 | Seq( 43 | "Model card_authorization_upserted: Event must contain a field whose name and type contain card or authorization", 44 | ), 45 | ) 46 | linter.validate(buildService("card_authorization_deleted", "card_authorization", "foo")) should be( 47 | Seq( 48 | "Model card_authorization_deleted: Event must contain a field whose name and type contain card or authorization", 49 | ), 50 | ) 51 | } 52 | 53 | it("deleted events can just use 'id' w/ type string") { 54 | linter.validate(buildService("card_authorization_deleted", "id", "string")) should be(Nil) 55 | linter.validate(buildService("card_authorization_deleted", "id", "object")) should be( 56 | Seq("Model card_authorization_deleted: Type of field 'id' must be 'string' and not 'object'"), 57 | ) 58 | } 59 | 60 | it("ignores legacy models") { 61 | linter.validate(buildService("item_origin_deleted", "foo", "item_origin")) should be(Nil) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/VersionModelsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint 2 | 3 | import io.apibuilder.spec.v0.models._ 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class VersionModelsSpec extends AnyFunSpec with Matchers { 8 | 9 | private[this] val linter = linters.VersionModels 10 | 11 | def buildService(fields: Seq[Field]): Service = { 12 | Services.Base.copy( 13 | models = Seq( 14 | Services.buildModel("user_version", fields), 15 | ), 16 | ) 17 | } 18 | 19 | val idField = Services.buildField("id", "string") 20 | val timestampField = Services.buildField("timestamp", "date-time-iso8601") 21 | val typeField = Services.buildField("type", "io.flow.common.v0.enums.change_type") 22 | val userField = Services.buildField("user", "user") 23 | 24 | it("no-op w/ imported user should not be an error") { 25 | val importedUserField = Services.buildField("user", "io.flow.common.v0.models.user") 26 | linter.validate(buildService(Seq(idField, timestampField, typeField, importedUserField))) should be(Nil) 27 | } 28 | 29 | it("no-op w/ imported expandable user should not be an error") { 30 | val importedUserField = Services.buildField("user", "io.flow.common.v0.models.expandable_user") 31 | linter.validate(buildService(Seq(idField, timestampField, typeField, importedUserField))) should be(Nil) 32 | } 33 | 34 | it("no-op w/ imported user summary should not be an error") { 35 | val importedUserField = Services.buildField("user", "io.flow.common.v0.models.user_summary") 36 | linter.validate(buildService(Seq(idField, timestampField, typeField, importedUserField))) should be(Nil) 37 | } 38 | 39 | it("no-op w/ correct fields") { 40 | linter.validate(buildService(Seq(idField, timestampField, typeField, userField))) should be(Nil) 41 | } 42 | 43 | it("error w/ extra field") { 44 | linter.validate( 45 | buildService( 46 | Seq( 47 | idField, 48 | timestampField, 49 | typeField, 50 | userField, 51 | Services.buildField("other"), 52 | ), 53 | ), 54 | ) should be(Seq("Model user_version: Must have exactly 4 fields: id, timestamp, type, user")) 55 | } 56 | 57 | it("error w/ missing field") { 58 | linter.validate( 59 | buildService( 60 | Seq( 61 | idField, 62 | timestampField, 63 | typeField, 64 | Services.buildField("other"), 65 | ), 66 | ), 67 | ) should be(Seq("Model user_version: Must have exactly 4 fields: id, timestamp, type, user")) 68 | } 69 | 70 | it("error w/ fields in incorrect order") { 71 | linter.validate( 72 | buildService( 73 | Seq( 74 | timestampField, 75 | typeField, 76 | userField, 77 | idField, 78 | ), 79 | ), 80 | ) should be(Seq("Model user_version: Must have exactly 4 fields: id, timestamp, type, user")) 81 | } 82 | 83 | it("error w/ invalid id type") { 84 | linter.validate( 85 | buildService( 86 | Seq( 87 | idField.copy(`type` = "long"), 88 | timestampField, 89 | typeField, 90 | userField, 91 | ), 92 | ), 93 | ) should be(Seq("Model user_version Field[id]: Must have type string and not long")) 94 | } 95 | 96 | it("error w/ invalid timestamp type") { 97 | linter.validate( 98 | buildService( 99 | Seq( 100 | idField, 101 | timestampField.copy(`type` = "long"), 102 | typeField, 103 | userField, 104 | ), 105 | ), 106 | ) should be(Seq("Model user_version Field[timestamp]: Must have type date-time-iso8601 and not long")) 107 | } 108 | 109 | it("error w/ invalid type type") { 110 | linter.validate( 111 | buildService( 112 | Seq( 113 | idField, 114 | timestampField, 115 | typeField.copy(`type` = "long"), 116 | userField, 117 | ), 118 | ), 119 | ) should be(Seq("Model user_version Field[type]: Must have type io.flow.common.v0.enums.change_type and not long")) 120 | } 121 | 122 | it("error w/ invalid user type") { 123 | linter.validate( 124 | buildService( 125 | Seq( 126 | idField, 127 | timestampField, 128 | typeField, 129 | userField.copy(`type` = "long"), 130 | ), 131 | ), 132 | ) should be( 133 | Seq("Model user_version Field[user]: Must have type user or user_summary or expandable_user and not long"), 134 | ) 135 | } 136 | 137 | it("error if field is not required") { 138 | linter.validate(buildService(Seq(idField.copy(required = false), timestampField, typeField, userField))) should be( 139 | Seq("Model user_version Field[id]: Must be required"), 140 | ) 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/lint/util/ExpansionsSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.lint.util 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class ExpansionsSpec extends AnyFunSpec with Matchers { 7 | 8 | it("fromFieldTypes") { 9 | Expansions.fromFieldTypes(Nil) should be(Nil) 10 | Expansions.fromFieldTypes(Seq("string")) should be(Nil) 11 | Expansions.fromFieldTypes(Seq("user")) should be(Nil) 12 | Expansions.fromFieldTypes(Seq("expandable_user")) should be(Seq("user")) 13 | Expansions.fromFieldTypes(Seq("string", "user", "expandable_user")) should be(Seq("user")) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/oneapi/OperationSortSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.oneapi 2 | 3 | import io.apibuilder.spec.v0.models.Method 4 | import io.flow.lint.Services 5 | import org.scalatest.funspec.AnyFunSpec 6 | import org.scalatest.matchers.should.Matchers 7 | 8 | class OperationSortSpec extends AnyFunSpec with Matchers { 9 | 10 | it("static paths") { 11 | Seq( 12 | Services.buildSimpleOperation( 13 | path = "/organizations/id", 14 | ), 15 | Services.buildSimpleOperation( 16 | path = "/organizations", 17 | ), 18 | ).sortBy(OperationSort.key).map(_.path) should equal( 19 | Seq("/organizations", "/organizations/id"), 20 | ) 21 | } 22 | 23 | it("GET before POST") { 24 | Seq( 25 | Services.buildSimpleOperation( 26 | method = Method.Post, 27 | path = "/organizations", 28 | ), 29 | Services.buildSimpleOperation( 30 | method = Method.Get, 31 | path = "/organizations", 32 | ), 33 | ).sortBy(OperationSort.key).map(_.method) should equal( 34 | Seq(Method.Get, Method.Post), 35 | ) 36 | } 37 | 38 | it("simple dynamic paths") { 39 | Seq( 40 | Services.buildSimpleOperation( 41 | path = "/:organization/experiences/:key", 42 | ), 43 | Services.buildSimpleOperation( 44 | path = "/:organization/experiences", 45 | ), 46 | ).sortBy(OperationSort.key).map(_.path) should equal( 47 | Seq("/:organization/experiences", "/:organization/experiences/:key"), 48 | ) 49 | } 50 | 51 | it("complex dynamic paths") { 52 | Seq( 53 | Services.buildSimpleOperation( 54 | path = "/:organization/experiences/:key", 55 | ), 56 | Services.buildSimpleOperation( 57 | path = "/:organization/experiences/items", 58 | ), 59 | ).sortBy(OperationSort.key).map(_.path) should equal( 60 | Seq("/:organization/experiences/items", "/:organization/experiences/:key"), 61 | ) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/proxy/ApiBuildAttributesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | import io.flow.helpers.ServiceHostHelpers 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class ApiBuildAttributesSpec extends AnyFunSpec with Matchers with ServiceHostHelpers { 8 | 9 | it("host with no attribute") { 10 | ApiBuildAttributes( 11 | Seq(serviceWithHost("user")), 12 | ).host("user") should be(None) 13 | } 14 | 15 | it("host with attribute") { 16 | ApiBuildAttributes( 17 | Seq(serviceWithHost("user"), serviceWithHost("foo", Some("bar"))), 18 | ).host("foo") should be(Some("bar")) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/proxy/ServiceHostResolverSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | import io.flow.helpers.ServiceHostHelpers 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class ServiceHostResolverSpec extends AnyFunSpec with Matchers with ServiceHostHelpers { 8 | 9 | it("host defaults to name of service") { 10 | val resolver = ServiceHostResolver( 11 | Seq( 12 | serviceWithHost("foo"), 13 | ), 14 | ) 15 | resolver.host("foo") should be("foo") 16 | } 17 | 18 | it("host strips internal suffixes") { 19 | val resolver = ServiceHostResolver( 20 | Seq( 21 | serviceWithHost("user"), 22 | serviceWithHost("user-internal"), 23 | serviceWithHost("user-internal-event"), 24 | ), 25 | ) 26 | resolver.host("user") should be("user") 27 | resolver.host("user-internal") should be("user") 28 | resolver.host("user-internal-event") should be("user") 29 | } 30 | 31 | it("respects attribute when specified") { 32 | val resolver = ServiceHostResolver( 33 | Seq( 34 | serviceWithHost("user", Some("foo")), 35 | serviceWithHost("user-internal", Some("bar")), 36 | serviceWithHost("user-internal-event", Some("baz")), 37 | ), 38 | ) 39 | resolver.host("user") should be("foo") 40 | resolver.host("user-internal") should be("bar") 41 | resolver.host("user-internal-event") should be("baz") 42 | } 43 | 44 | it("respects attribute 'host' on parent") { 45 | val resolver = ServiceHostResolver( 46 | Seq( 47 | serviceWithHost("user", Some("foo")), 48 | serviceWithHost("user-internal"), 49 | ), 50 | ) 51 | resolver.host("user") should be("foo") 52 | resolver.host("user-internal") should be("foo") 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/proxy/TextSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.proxy 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class TextSpec extends AnyFunSpec with Matchers { 7 | 8 | it("stripSuffix") { 9 | Text.stripSuffix("", "-internal") should be("") 10 | Text.stripSuffix("currency", "-internal") should be("currency") 11 | Text.stripSuffix("currency-internal", "-internal") should be("currency") 12 | Text.stripSuffix("currency-internal-foo", "-internal") should be("currency-internal-foo") 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/test/scala/io/flow/stream/EventUnionTypeMatcherSpec.scala: -------------------------------------------------------------------------------- 1 | package io.flow.stream 2 | 3 | import io.apibuilder.spec.v0.models.Field 4 | import org.scalatest.funspec.AnyFunSpec 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | class EventUnionTypeMatcherSpec extends AnyFunSpec with Matchers { 8 | it("matches field name, field type and union type name the same") { 9 | val field = Field(name = "organization", `type` = "organization", required = true) 10 | EventUnionTypeMatcher.matchFieldToPayloadType(field, "organization") shouldBe true 11 | } 12 | 13 | it("field type with namespace") { 14 | val field = Field(name = "organization", `type` = "io.flow.blah.v0.organization", required = true) 15 | EventUnionTypeMatcher.matchFieldToPayloadType(field, "organization") shouldBe true 16 | } 17 | 18 | it("field name short of union type") { 19 | val field = Field(name = "organization", `type` = "foo_organization_bar", required = true) 20 | EventUnionTypeMatcher.matchFieldToPayloadType(field, "foo_organization_bar") shouldBe true 21 | } 22 | 23 | it("field type short of union type") { 24 | val field = Field(name = "foo_organization_bar", `type` = "organization", required = true) 25 | EventUnionTypeMatcher.matchFieldToPayloadType(field, "foo_organization_bar") shouldBe true 26 | } 27 | 28 | it("field type short of union type with namespace") { 29 | val field = Field(name = "foo_organization_bar", `type` = "io.flow.blah.v0.organization", required = true) 30 | EventUnionTypeMatcher.matchFieldToPayloadType(field, "foo_organization_bar") shouldBe true 31 | } 32 | 33 | it("field name and type short of union type with namespace") { 34 | val field = Field(name = "organization", `type` = "io.flow.blah.v0.organization", required = true) 35 | EventUnionTypeMatcher.matchFieldToPayloadType(field, "foo_organization_bar") shouldBe true 36 | } 37 | 38 | it("field name short and type long of union type with namespace") { 39 | val field = Field(name = "organization", `type` = "io.flow.blah.v0.foo_organization_bar", required = true) 40 | EventUnionTypeMatcher.matchFieldToPayloadType(field, "organization") shouldBe true 41 | } 42 | } 43 | --------------------------------------------------------------------------------