├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── assets ├── .DS_Store ├── favicon.png ├── favicon.psd ├── icon.png ├── logo-blue.png ├── logo-dark.png ├── logo-light.png ├── logo.psd └── mocky-social.png ├── client ├── .babelrc ├── .editorconfig ├── .env ├── .eslintignore ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .yarnclean ├── README.md ├── package.json ├── public │ ├── apple-touch-icon.png │ ├── assets │ │ └── mocky-social-twitter-final.png │ ├── css │ │ ├── iconsmind.css │ │ └── socicon.css │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── favicon.png │ ├── fonts │ │ ├── iconsmind.eot │ │ ├── iconsmind.ttf │ │ ├── iconsmind.woff │ │ ├── socicon.eot │ │ ├── socicon.svg │ │ ├── socicon.ttf │ │ └── socicon.woff │ ├── index.html │ └── robots.txt ├── server.js ├── src │ ├── components │ │ ├── CookieNotification │ │ │ ├── CookieNotification.tsx │ │ │ └── styles.css │ │ ├── ErrorBoundary │ │ │ └── ErrorBoundary.tsx │ │ ├── Loader │ │ │ ├── Loader.tsx │ │ │ └── styles.css │ │ ├── ScrollToTopOnNavigationChange │ │ │ └── ScrollToTopOnNavigationChange.tsx │ │ ├── SelectCharset │ │ │ ├── SelectCharset.tsx │ │ │ └── models.ts │ │ ├── SelectContentType │ │ │ ├── SelectContentType.tsx │ │ │ └── models.ts │ │ ├── SelectExpirationTime │ │ │ └── SelectExpirationTime.tsx │ │ ├── SelectHttpStatusCode │ │ │ ├── SelectHttpStatusCode.tsx │ │ │ ├── models.ts │ │ │ └── styles.css │ │ ├── TextareaCodeEditor │ │ │ └── TextareaCodeEditor.tsx │ │ ├── TextareaHeaders │ │ │ └── TextareaHeaders.tsx │ │ └── TrackPageView │ │ │ └── TrackPageView.tsx │ ├── index.css │ ├── index.tsx │ ├── modules │ │ ├── about │ │ │ ├── About.tsx │ │ │ └── components │ │ │ │ ├── ProfilePicture.css │ │ │ │ ├── ProfilePicture.tsx │ │ │ │ └── assets │ │ │ │ └── profile.jpg │ │ ├── designer │ │ │ ├── Designer.tsx │ │ │ ├── NewMockConfirmation.tsx │ │ │ ├── assets │ │ │ │ └── clem-onojeghuo-DoA2duXyzRM-unsplash.jpg │ │ │ ├── components │ │ │ │ ├── DesignerTitle.tsx │ │ │ │ ├── NewMockFeatures.tsx │ │ │ │ └── Pub.tsx │ │ │ ├── form │ │ │ │ ├── CleanConfirmationOnSubmit.tsx │ │ │ │ ├── NewMockForm.tsx │ │ │ │ ├── NewMockFormView.tsx │ │ │ │ ├── models.ts │ │ │ │ ├── styles.css │ │ │ │ └── types.ts │ │ │ └── styles.css │ │ ├── faq │ │ │ └── Faq.tsx │ │ ├── home │ │ │ ├── Home.tsx │ │ │ └── components │ │ │ │ ├── CallToActionDesignMock.tsx │ │ │ │ ├── Features.tsx │ │ │ │ ├── HeadTitle.tsx │ │ │ │ ├── WhatIsMocky.tsx │ │ │ │ └── assets │ │ │ │ └── carbon.svg │ │ ├── maintenance │ │ │ ├── Maintenance.tsx │ │ │ └── assets │ │ │ │ └── inner-6.jpg │ │ ├── manager │ │ │ ├── Manager.tsx │ │ │ ├── components │ │ │ │ ├── DeleteMockInformation.tsx │ │ │ │ ├── ManagerTitle.tsx │ │ │ │ ├── MockGone.tsx │ │ │ │ └── SearchingMockLoader.tsx │ │ │ ├── delete │ │ │ │ ├── DeleteSuccessful.tsx │ │ │ │ └── DeletionApproval.tsx │ │ │ ├── styles.css │ │ │ ├── table │ │ │ │ ├── EmptyPlaceholder.tsx │ │ │ │ ├── ManagerTable.tsx │ │ │ │ └── assets │ │ │ │ │ └── sand-bg.jpg │ │ │ └── types.ts │ │ ├── policies │ │ │ ├── CookiePolicy.tsx │ │ │ └── styles.css │ │ ├── routing │ │ │ ├── Page404.tsx │ │ │ ├── Page500.tsx │ │ │ └── Routing.tsx │ │ ├── skeleton │ │ │ ├── Footer.tsx │ │ │ ├── NavBar.tsx │ │ │ └── assets │ │ │ │ ├── logo-blue.png │ │ │ │ ├── logo-dark.png │ │ │ │ └── logo-light.png │ │ └── sponso-abstract │ │ │ ├── SponsoConfirmation.jsx │ │ │ ├── SponsoFooter.jsx │ │ │ ├── assets │ │ │ └── chi-hang-leong-hehYcAGhbmY-unsplash.jpg │ │ │ ├── models.ts │ │ │ └── styles.css │ ├── react-app-env.d.ts │ ├── redux │ │ ├── mocks │ │ │ ├── slice.ts │ │ │ └── types.ts │ │ └── store.ts │ ├── serviceWorker.ts │ ├── services │ │ ├── Analytics │ │ │ └── GA.ts │ │ ├── HTTP.ts │ │ └── MockyAPI │ │ │ ├── MockAPITransformer.ts │ │ │ ├── MockyAPI.ts │ │ │ └── types.ts │ └── setupTests.ts ├── tsconfig.json └── yarn.lock └── server ├── .gitignore ├── .jvmopts ├── .scalafmt.conf ├── README.md ├── build.sbt ├── project ├── Dependencies.scala ├── build.properties └── plugins.sbt ├── scripts ├── admin │ ├── delete.sh │ └── stats.sh ├── api │ ├── create.sh │ ├── delete.sh │ ├── get.sh │ ├── stats.sh │ └── update.sh └── play-v3.sh ├── src ├── it │ ├── resources │ │ └── logback.xml │ └── scala │ │ └── MockyServerSpec.scala ├── main │ ├── resources │ │ ├── application.conf │ │ ├── db │ │ │ └── migration │ │ │ │ ├── V001__init_table_mocks_v2_v3.sql │ │ │ │ ├── V002__fake_legacy_data.sql │ │ │ │ └── V003__fake_v3_data.sql │ │ └── logback.xml │ └── scala │ │ └── io │ │ └── mocky │ │ ├── HttpServer.scala │ │ ├── Routing.scala │ │ ├── ServerApp.scala │ │ ├── config │ │ └── Config.scala │ │ ├── db │ │ ├── Database.scala │ │ └── DoobieLogHandler.scala │ │ ├── http │ │ ├── HttpMockResponse.scala │ │ ├── JsonMarshalling.scala │ │ └── middleware │ │ │ ├── Authorization.scala │ │ │ ├── IPThrottler.scala │ │ │ ├── Jsonp.scala │ │ │ └── Sleep.scala │ │ ├── models │ │ ├── Gate.scala │ │ ├── admin │ │ │ └── Stats.scala │ │ ├── errors │ │ │ └── MockNotFoundError.scala │ │ └── mocks │ │ │ ├── Mock.scala │ │ │ ├── MockCreatedResponse.scala │ │ │ ├── MockResponse.scala │ │ │ ├── MockStats.scala │ │ │ ├── actions │ │ │ ├── CreateUpdateMock.scala │ │ │ └── DeleteMock.scala │ │ │ ├── enums │ │ │ └── Expiration.scala │ │ │ └── feedbacks │ │ │ └── MockCreated.scala │ │ ├── repositories │ │ ├── MockV2Repository.scala │ │ └── MockV3Repository.scala │ │ ├── services │ │ ├── DesignerService.scala │ │ ├── MockAdminApiService.scala │ │ ├── MockApiService.scala │ │ ├── MockRunnerService.scala │ │ └── StatusService.scala │ │ └── utils │ │ ├── DateUtil.scala │ │ └── HttpUtil.scala └── test │ └── scala │ └── io │ └── mocky │ ├── data │ └── Fixtures.scala │ ├── http │ └── middleware │ │ ├── IPThrottlerSpec.scala │ │ └── JsonpSpec.scala │ └── services │ ├── MockAPIServiceSpec.scala │ └── MockRunnerServiceSpec.scala └── stack ├── docker-compose.yml └── servers.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://www.buymeacoffee.com/julienlafont"] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot documentation 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Maintain dependencies for GitHub Actions 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | 12 | # Maintain dependencies for npm 13 | - package-ecosystem: "npm" 14 | directory: "/client" 15 | schedule: 16 | interval: "daily" 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Development CI 2 | 3 | on: 4 | push: 5 | branches: ['**', '!master'] 6 | pull_request: 7 | branches: ['**', '!master'] 8 | 9 | jobs: 10 | build-server: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2.3.4 15 | - name: Set up JDK 1.8 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 1.8 19 | - name: Cache SBT ivy cache 20 | uses: actions/cache@v2 21 | with: 22 | path: ~/.ivy2/cache 23 | key: ${{ runner.os }}-sbt-ivy-cache-${{ hashFiles('**/build.sbt') }} 24 | - name: Cache SBT 25 | uses: actions/cache@v2 26 | with: 27 | path: ~/.sbt 28 | key: ${{ runner.os }}-sbt-${{ hashFiles('**/build.sbt') }} 29 | - name: Run tests 30 | run: cd server; sbt test it:test 31 | 32 | build-frontend: 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - uses: actions/checkout@v2.3.4 37 | - name: Use Node.js 38 | uses: actions/setup-node@v2.1.2 39 | with: 40 | node-version: '14.x' 41 | - name: Install dependencies 42 | run: cd client; yarn --frozen-lockfile 43 | # Disabled step because... there is not JS tests... 44 | # - name: Run tests 45 | # run: cd client; yarn test 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2.3.4 15 | 16 | - name: Fetch tags 17 | uses: fnkr/github-action-git-bash@v1.1 18 | with: 19 | args: git fetch --tags 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Extract last tag name 23 | id: previoustag 24 | uses: WyriHaximus/github-action-get-previous-tag@master 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: API - Cache SBT ivy cache 29 | uses: actions/cache@v2 30 | with: 31 | path: ~/.ivy2/cache 32 | key: ${{ runner.os }}-sbt-ivy-cache-${{ hashFiles('**/build.sbt') }} 33 | - name: API - Cache SBT 34 | uses: actions/cache@v2 35 | with: 36 | path: ~/.sbt 37 | key: ${{ runner.os }}-sbt-${{ hashFiles('**/build.sbt') }} 38 | - name: API - Set up JDK 1.8 39 | uses: actions/setup-java@v1 40 | with: 41 | java-version: 1.8 42 | - name: API - Run tests and generate release artifact 43 | run: cd server; sbt test it:test dist 44 | - name: API - Rename artifact 45 | uses: canastro/copy-file-action@master 46 | with: 47 | source: server/target/universal/mocky-2020*.zip 48 | target: mocky-api-release.zip 49 | 50 | - name: CLIENT - Use Node.js 51 | uses: actions/setup-node@v2.1.2 52 | with: 53 | node-version: '14.x' 54 | - name: CLIENT - Install dependencies 55 | run: cd client; yarn --frozen-lockfile 56 | # Disabled step because... there is not JS tests... 57 | # - name: CLIENT - Run tests 58 | # run: cd client; yarn test 59 | - name: CLIENT - Build production app 60 | run: cd client; yarn build 61 | - name: CLIENT - Zip build folder 62 | run: zip -r mocky-front-release.zip client/build 63 | 64 | - name: Create Githbub Release 65 | id: create_release 66 | uses: actions/create-release@v1.1.4 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | tag_name: ${{ steps.previoustag.outputs.tag }} 71 | release_name: Release ${{ steps.previoustag.outputs.tag }} 72 | draft: false 73 | prerelease: false 74 | - name: Upload API release artifact in Github Release 75 | id: upload-api-release-asset 76 | uses: actions/upload-release-asset@v1 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | with: 80 | upload_url: ${{ steps.create_release.outputs.upload_url }} 81 | asset_path: mocky-api-release.zip 82 | asset_name: mocky-api-${{ steps.previoustag.outputs.tag }}.zip 83 | asset_content_type: application/zip 84 | - name: Upload CLIENT release artifact in Github Release 85 | id: upload-front-release-asset 86 | uses: actions/upload-release-asset@v1 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | with: 90 | upload_url: ${{ steps.create_release.outputs.upload_url }} 91 | asset_path: mocky-front-release.zip 92 | asset_name: mocky-front-${{ steps.previoustag.outputs.tag }}.zip 93 | asset_content_type: application/zip 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | server/.idea 3 | server/src/main/resources/application.clever-cloud.conf 4 | 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mocky.io Lockdown Edition (2020) 2 | 3 | ![Release CI](https://github.com/julien-lafont/Mocky/workflows/Release%20CI/badge.svg) 4 | ![Development CI](https://github.com/julien-lafont/Mocky/workflows/Development%20CI/badge.svg?branch=develop) 5 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/julien-lafont/Mocky) 6 | 7 | ## What is mocky? 8 | 9 | Mocky is a simple app which allows to generate custom HTTP responses. It's helpful when you have to request a build-in-progress WS, when you want to mock the backend response in a single app, or when you want to test your WS client. 10 | 11 | Don't wait for the backend to be ready, generate custom API responses with Mocky and start working on your application straight away 12 | 13 | Mocky is a **free** and **unlimited** online service, accessible on **https://www.mocky.io**. Read the [FAQ](https://designer.mocky.io/faq) for more information about allowed usage. 14 | 15 | _Everything is done to provide the best service quality, but I don't guarantee the sustainability or the stability of the application._ 16 | 17 | ## How to host my own Mocky instance 18 | 19 | Work in progress. Please come back in a few days! 20 | 21 | ## Architecture 22 | 23 | ### API 24 | 25 | Mocky API is written in Scala with the [HTTP4s](https://http4s.org/) server. Mocks are stored into PostgreSQL database with [Doobie](https://tpolecat.github.io/doobie/). 26 | 27 | See the [server/README.md](https://github.com/julien-lafont/Mocky/blob/master/server/README.md) for more information about how to build/run the API (WIP). 28 | 29 | ### Frontend 30 | 31 | React/Redux application written in typescript. 32 | 33 | See the [client/README.md](https://github.com/julien-lafont/Mocky/blob/master/client/README.md) for more information about how to build/run the frontend. 34 | 35 | ### Hosting 36 | 37 | Mocky is currently hosted on [Clever-Cloud](https://www.clever-cloud.com/en/). 38 | 39 | > Clever Cloud helps companies and IT professionals to achieve software delivery faster, reduce their feedback loop, focus on their core value and stop worrying about their hosting infrastructure by providing a solution for application sustainability. 40 | 41 | ## Contributors 42 | 43 | - [Julien lafont](https://twitter.com/julien_lafont) 44 | 45 | ## License 46 | 47 | Mocky is licensed under the [Apache License, Version 2.0](https://github.com/julien-lafont/Mocky/blob/master/LICENSE) (the “License”); you may not use this software except in compliance with the License. 48 | 49 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 50 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | > 3.0 | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Please report all security issues to [@julien_lafont](https://www.twitter.com/julien_lafont). 12 | -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/.DS_Store -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/favicon.png -------------------------------------------------------------------------------- /assets/favicon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/favicon.psd -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/icon.png -------------------------------------------------------------------------------- /assets/logo-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/logo-blue.png -------------------------------------------------------------------------------- /assets/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/logo-dark.png -------------------------------------------------------------------------------- /assets/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/logo-light.png -------------------------------------------------------------------------------- /assets/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/logo.psd -------------------------------------------------------------------------------- /assets/mocky-social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/assets/mocky-social.png -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-app"] 3 | } 4 | 5 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_GOOGLE_ANALYTICS_TRACKING_ID="UA-40626968-3" 2 | REACT_APP_DOMAIN="http://localhost:3000" 3 | REACT_APP_API_URL="http://localhost:8080" 4 | REACT_APP_MAINTENANCE=false 5 | REACT_APP_SHOW_PROMOTING_PANEL=true 6 | REACT_APP_SHOW_SPONSORING=true -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | build/* 2 | public/* 3 | src/react-app-env.d.ts 4 | src/serviceWorker.ts -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/.prettierignore: -------------------------------------------------------------------------------- 1 | src/react-app-env.d.ts -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "semi": true 7 | } 8 | -------------------------------------------------------------------------------- /client/.yarnclean: -------------------------------------------------------------------------------- 1 | # test directories 2 | __tests__ 3 | test 4 | tests 5 | powered-test 6 | 7 | # asset directories 8 | docs 9 | doc 10 | website 11 | images 12 | assets 13 | 14 | # examples 15 | example 16 | examples 17 | 18 | # code coverage directories 19 | coverage 20 | .nyc_output 21 | 22 | # build scripts 23 | Makefile 24 | Gulpfile.js 25 | Gruntfile.js 26 | 27 | # configs 28 | appveyor.yml 29 | circle.yml 30 | codeship-services.yml 31 | codeship-steps.yml 32 | wercker.yml 33 | .tern-project 34 | .gitattributes 35 | .editorconfig 36 | .*ignore 37 | .eslintrc 38 | .jshintrc 39 | .flowconfig 40 | .documentup.json 41 | .yarn-metadata.json 42 | .travis.yml 43 | 44 | # misc 45 | *.md 46 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Mocky Frontend 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) template. 4 | 5 | ## Environment variables 6 | 7 | You must define these environment variables. For local development, fill the `.env` file. 8 | 9 | ### Domain & API 10 | 11 | - `REACT_APP_DOMAIN="http://localhost:3000"`: What is the URL of this frontend 12 | - `REACT_APP_API_URL="https://api.mocky.site"`: What is the URL of the mocky API 13 | 14 | ### Maintenance mode 15 | 16 | - `REACT_APP_MAINTENANCE=false`: Set to true to activate the maintenance page 17 | 18 | ### Analytics tracking 19 | 20 | - `REACT_APP_GOOGLE_ANALYTICS_TRACKING_ID="UA-XXXXXX-X"`: Fill this variable if you want to activate Google analytics tracking 21 | 22 | ### Customization 23 | 24 | - `REACT_APP_SHOW_PROMOTING_PANEL=true`: Display or not the promoting panel (give a shoot on twitter, buy me a beer) 25 | 26 | ## Available Scripts 27 | 28 | In the project directory, you can run: 29 | 30 | ### `yarn start:dev` 31 | 32 | Runs the app in the development mode.
33 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 34 | 35 | The page will reload if you make edits.
36 | You will also see any lint errors in the console. 37 | 38 | ### `yarn test` 39 | 40 | Launches the test runner in the interactive watch mode.
41 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 42 | 43 | ### `yarn build` 44 | 45 | Builds the app for production to the `build` folder.
46 | It correctly bundles React in production mode and optimizes the build for the best performance. 47 | 48 | The build is minified and the filenames include the hashes.
49 | Your app is ready to be deployed! 50 | 51 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 52 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mocky", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-svg-core": "^1.2.28", 7 | "@fortawesome/free-brands-svg-icons": "^5.13.0", 8 | "@fortawesome/free-regular-svg-icons": "^5.15.1", 9 | "@fortawesome/free-solid-svg-icons": "^5.13.0", 10 | "@fortawesome/react-fontawesome": "^0.1.13", 11 | "@reduxjs/toolkit": "^1.3.5", 12 | "@testing-library/dom": "^7.22.0", 13 | "@testing-library/jest-dom": "^4.2.4", 14 | "@testing-library/react": "^9.3.2", 15 | "@testing-library/user-event": "^12.2.2", 16 | "@types/jest": "^26.0.9", 17 | "@types/moment": "^2.13.0", 18 | "@types/node": "^14.0.27", 19 | "@types/randomstring": "^1.1.6", 20 | "@types/react": "^17.0.0", 21 | "@types/react-copy-to-clipboard": "^4.3.0", 22 | "@types/react-dom": "^16.9.0", 23 | "@types/react-ga": "^2.3.0", 24 | "@types/react-loader-spinner": "^3.1.0", 25 | "@types/react-redux": "^7.1.7", 26 | "@types/react-router-dom": "^5.1.5", 27 | "@types/react-select": "^3.0.13", 28 | "@types/react-textarea-autosize": "^4.3.5", 29 | "@types/unique-random-array": "^2.0.1", 30 | "@types/word-wrap": "^1.2.1", 31 | "@types/yup": "^0.29.4", 32 | "connect-history-api-fallback": "^1.6.0", 33 | "express": "^4.17.1", 34 | "express-sslify": "^1.2.0", 35 | "formik": "^2.1.4", 36 | "moment": "^2.26.0", 37 | "prop-types": "^15.7.2", 38 | "randomstring": "^1.1.5", 39 | "react": "^16.13.1", 40 | "react-copy-to-clipboard": "^5.0.2", 41 | "react-dom": "^16.13.1", 42 | "react-ga": "^3.0.0", 43 | "react-loader-spinner": "^3.1.14", 44 | "react-moment": "^0.9.7", 45 | "react-redux": "^7.2.0", 46 | "react-router-dom": "^5.2.0", 47 | "react-scripts": "3.4.1", 48 | "react-select": "^3.1.0", 49 | "react-textarea-autosize": "^8.0.1", 50 | "react-typed": "^1.2.0", 51 | "react-use": "^15.3.4", 52 | "redux": "^4.0.5", 53 | "redux-localstorage-simple": "^2.2.0", 54 | "typescript": "~3.9.5", 55 | "unique-random-array": "^2.0.0", 56 | "word-wrap": "^1.2.3", 57 | "yup": "^0.29.1" 58 | }, 59 | "scripts": { 60 | "start:dev": "react-scripts start", 61 | "start": "node server.js", 62 | "build": "react-scripts build", 63 | "test": "react-scripts test", 64 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src --color", 65 | "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"" 66 | }, 67 | "eslintConfig": { 68 | "extends": [ 69 | "react-app" 70 | ] 71 | }, 72 | "browserslist": { 73 | "production": [ 74 | ">0.2%", 75 | "not dead", 76 | "not op_mini all" 77 | ], 78 | "development": [ 79 | "last 1 chrome version", 80 | "last 1 firefox version", 81 | "last 1 safari version" 82 | ] 83 | }, 84 | "devDependencies": { 85 | "eslint": "6.8.0", 86 | "eslint-config-airbnb": "18.1.0", 87 | "eslint-plugin-import": "2.22.0", 88 | "eslint-plugin-jsx-a11y": "6.2.3", 89 | "eslint-plugin-react": "7.20.0", 90 | "eslint-plugin-react-hooks": "2.5.1", 91 | "prettier": "^2.0.5" 92 | }, 93 | "engines": { 94 | "node": "^14" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/assets/mocky-social-twitter-final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/assets/mocky-social-twitter-final.png -------------------------------------------------------------------------------- /client/public/css/iconsmind.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'iconsmind'; 3 | src: url('../fonts/iconsmind.eot?#iefix-rdmvgc') format('embedded-opentype'); 4 | src: url('../fonts/iconsmind.woff') format('woff'), url('../fonts/iconsmind.ttf') format('truetype'); 5 | font-weight: normal; 6 | font-style: normal; 7 | } 8 | 9 | [class^='icon-'], 10 | [class*=' icon-'] { 11 | font-family: 'iconsmind'; 12 | speak: none; 13 | font-style: normal; 14 | font-weight: normal; 15 | font-variant: normal; 16 | text-transform: none; 17 | line-height: 1; 18 | /* Better Font Rendering =========== */ 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | .icon-Money-2:before { 23 | content: '\eac6'; 24 | } 25 | .icon-Box-Full:before { 26 | content: '\e6e6'; 27 | } 28 | .icon-Chemical:before { 29 | content: '\e74d'; 30 | } 31 | .icon-Code-Window:before { 32 | content: '\e792'; 33 | } 34 | -------------------------------------------------------------------------------- /client/public/css/socicon.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Socicon'; 3 | src: url('../fonts/socicon.eot?a93r5t'); 4 | src: url('../fonts/socicon.eot?a93r5t#iefix') format('embedded-opentype'), 5 | url('../fonts/socicon.ttf?a93r5t') format('truetype'), url('../fonts/socicon.woff?a93r5t') format('woff'), 6 | url('../fonts/socicon.svg?a93r5t#Socicon') format('svg'); 7 | font-weight: normal; 8 | font-style: normal; 9 | } 10 | 11 | [class^='socicon-'], 12 | [class*=' socicon-'] { 13 | /* use !important to prevent issues with browser extensions that change fonts */ 14 | font-family: 'Socicon' !important; 15 | speak: none; 16 | font-style: normal; 17 | font-weight: normal; 18 | font-variant: normal; 19 | text-transform: none; 20 | line-height: 1; 21 | 22 | /* Better Font Rendering =========== */ 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | .socicon-twitter:before { 28 | content: '\e08d'; 29 | } 30 | .socicon-github:before { 31 | content: '\e032'; 32 | } 33 | .socicon-linkedin:before { 34 | content: '\e04c'; 35 | } 36 | -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/favicon.png -------------------------------------------------------------------------------- /client/public/fonts/iconsmind.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/fonts/iconsmind.eot -------------------------------------------------------------------------------- /client/public/fonts/iconsmind.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/fonts/iconsmind.ttf -------------------------------------------------------------------------------- /client/public/fonts/iconsmind.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/fonts/iconsmind.woff -------------------------------------------------------------------------------- /client/public/fonts/socicon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/fonts/socicon.eot -------------------------------------------------------------------------------- /client/public/fonts/socicon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/fonts/socicon.ttf -------------------------------------------------------------------------------- /client/public/fonts/socicon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/public/fonts/socicon.woff -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mocky: The world's easiest & fastest tool to mock your APIs 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 38 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /client/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const history = require('connect-history-api-fallback'); 3 | const enforce = require('express-sslify'); 4 | 5 | const app = express(); 6 | 7 | // allow to call history-mode route 8 | app.use(history()); 9 | 10 | // Redirect http to https (with "trustProtoHeader" because the app will be behind a LB) 11 | app.use(enforce.HTTPS({ trustProtoHeader: true })); 12 | 13 | // Serve all the files in '/build' directory 14 | app.use(express.static('build')); 15 | 16 | app.listen(process.env.PORT, '0.0.0.0', () => { 17 | console.log(`Mocky is running on port ${process.env.PORT}`); // eslint-disable-line no-console 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/CookieNotification/CookieNotification.tsx: -------------------------------------------------------------------------------- 1 | import './styles.css'; 2 | 3 | import React from 'react'; 4 | import { useCookie } from 'react-use'; 5 | 6 | import { faTimes as iconClose } from '@fortawesome/free-solid-svg-icons'; 7 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 8 | import { NavLink } from 'react-router-dom'; 9 | 10 | export default () => { 11 | const [cookieDefined, setCookie] = useCookie('cookie-banner'); 12 | 13 | return ( 14 | <> 15 | {!cookieDefined && ( 16 |
17 |
18 |

19 | By using our website you agree to our Cookie Policy 20 |

21 |
22 | 23 |
24 | 27 |
28 |
29 | )} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /client/src/components/CookieNotification/styles.css: -------------------------------------------------------------------------------- 1 | .cookie--block { 2 | z-index: 2147483635; 3 | position: fixed; 4 | max-width: 450px; 5 | left: 0; 6 | right: 0; 7 | bottom: 12px; 8 | margin: 0 auto; 9 | } 10 | 11 | .cookie--body { 12 | padding-top: 10px; 13 | padding-right: 45px; 14 | padding-bottom: 10px; 15 | padding-left: 30px; 16 | color: #ffffff; 17 | background-color: rgba(37, 37, 37, 1); 18 | box-shadow: 0 10px 24px 0 rgba(54, 61, 77, 0.5); 19 | border: 1px solid #aaa; 20 | } 21 | 22 | .cookie--description { 23 | line-height: 1.5; 24 | font-size: 14px; 25 | margin: 0 24px 0 0; 26 | font-weight: 400; 27 | } 28 | 29 | .cookie--description a { 30 | color: #fff; 31 | } 32 | 33 | .cookie--close { 34 | position: absolute; 35 | top: -9px; 36 | right: 0px; 37 | padding: 20px; 38 | } 39 | 40 | .cookie--close button { 41 | color: #666666; 42 | height: auto; 43 | border: 0; 44 | background-color: transparent; 45 | cursor: pointer; 46 | } 47 | 48 | .cookie--close svg:hover { 49 | color: #aaa; 50 | } 51 | -------------------------------------------------------------------------------- /client/src/components/ErrorBoundary/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo } from 'react'; 2 | import Page500 from '../../modules/routing/Page500'; 3 | 4 | type ErrorBoundaryState = { 5 | hasError: Boolean; 6 | }; 7 | 8 | class ErrorBoundary extends Component<{}, ErrorBoundaryState> { 9 | private initialState: ErrorBoundaryState = { 10 | hasError: false, 11 | }; 12 | 13 | public state: ErrorBoundaryState = this.initialState; 14 | 15 | public static getDerivedStateFromError(): ErrorBoundaryState { 16 | return { hasError: true }; 17 | } 18 | 19 | public componentDidCatch(error: Error, info: ErrorInfo) { 20 | console.error('ErrorBoundary caught an error', error, info); 21 | } 22 | 23 | public render() { 24 | if (this.state.hasError) { 25 | return ; 26 | } 27 | 28 | return this.props.children; 29 | } 30 | } 31 | 32 | export default ErrorBoundary; 33 | -------------------------------------------------------------------------------- /client/src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { default as Loading } from 'react-loader-spinner'; 3 | 4 | import 'react-loader-spinner/dist/loader/css/react-spinner-loader.css'; 5 | import './styles.css'; 6 | 7 | export default () => ( 8 |
9 | 10 |
11 | ); 12 | -------------------------------------------------------------------------------- /client/src/components/Loader/styles.css: -------------------------------------------------------------------------------- 1 | .loaderWrapper { 2 | position: absolute; 3 | top: 30px; 4 | left: 283px; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/components/ScrollToTopOnNavigationChange/ScrollToTopOnNavigationChange.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | 4 | export default () => { 5 | const { pathname } = useLocation(); 6 | 7 | useEffect(() => { 8 | window.scrollTo(0, 0); 9 | }, [pathname]); 10 | 11 | return null; 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/components/SelectCharset/SelectCharset.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Select from 'react-select'; 3 | import { charsetOptions } from './models'; 4 | import { FieldProps } from 'formik'; 5 | 6 | const customStyles = { 7 | input: (provided: any, state: any) => { 8 | return { height: 'fit-content' }; 9 | }, 10 | }; 11 | 12 | const SelectCharset = ({ field, form: { setFieldValue }, ...props }: FieldProps & { label: string }) => { 13 | return ( 14 | option.value === field.value) : '') as any} 57 | onChange={(option) => setFieldValue(field.name, (option as any).value)} 58 | /> 59 | ); 60 | }; 61 | 62 | export default SelectHttpStatusCode; 63 | -------------------------------------------------------------------------------- /client/src/components/SelectHttpStatusCode/models.ts: -------------------------------------------------------------------------------- 1 | import { GroupedOptionsType } from 'react-select'; 2 | 3 | export interface StatusOption { 4 | value: string; 5 | label: string; 6 | highlight?: boolean; 7 | } 8 | 9 | type StatusGroupedOptions = GroupedOptionsType; 10 | 11 | export const defaultValue: StatusOption = { value: '200', label: 'OK', highlight: true }; 12 | 13 | export const status: StatusGroupedOptions = [ 14 | { 15 | label: '1xx Informational response', 16 | options: [ 17 | { value: '100', label: 'Continue' }, 18 | { value: '101', label: 'Switching Protocols' }, 19 | { value: '102', label: 'Processing' }, 20 | ], 21 | }, 22 | { 23 | label: '2xx Success', 24 | options: [ 25 | { value: '200', label: 'OK', highlight: true }, 26 | { value: '201', label: 'Created', highlight: true }, 27 | { value: '202', label: 'Accepted' }, 28 | { value: '203', label: 'Non-Authoritative Information' }, 29 | { value: '204', label: 'No Content', highlight: true }, 30 | { value: '205', label: 'Reset Content' }, 31 | { value: '206', label: 'Partial Content' }, 32 | { value: '207', label: 'Multi-Status' }, 33 | { value: '208', label: 'Already Reported' }, 34 | { value: '226', label: 'IM Used' }, 35 | ], 36 | }, 37 | { 38 | label: '3xx Redirection', 39 | options: [ 40 | { value: '300', label: 'Multiple Choices' }, 41 | { value: '301', label: 'Moved Permanently' }, 42 | { value: '302', label: 'Found' }, 43 | { value: '303', label: 'See Other' }, 44 | { value: '304', label: 'Not Modified' }, 45 | { value: '305', label: 'Use Proxy' }, 46 | { value: '306', label: 'Switch Proxy' }, 47 | { value: '307', label: 'Temporary Redirect' }, 48 | { value: '308', label: 'Permanent Redirect' }, 49 | ], 50 | }, 51 | { 52 | label: '4xx Client errors', 53 | options: [ 54 | { value: '400', label: 'Bad Request', highlight: true }, 55 | { value: '401', label: 'Unauthorized', highlight: true }, 56 | { value: '402', label: 'Payment Required', highlight: true }, 57 | { value: '403', label: 'Forbidden', highlight: true }, 58 | { value: '404', label: 'Not Found', highlight: true }, 59 | { value: '405', label: 'Method Not Allowed' }, 60 | { value: '406', label: 'Not Acceptable' }, 61 | { value: '407', label: 'Proxy Authentication Required' }, 62 | { value: '408', label: 'Request Timeout' }, 63 | { value: '409', label: 'Conflict' }, 64 | { value: '410', label: 'Gone' }, 65 | { value: '411', label: 'Length Required' }, 66 | { value: '412', label: 'Precondition Failed' }, 67 | { value: '413', label: 'Request Entity Too Large' }, 68 | { value: '414', label: 'Request-URI Too Long' }, 69 | { value: '415', label: 'Unsupported Media Type' }, 70 | { value: '416', label: 'Requested Range Not Satisfiable' }, 71 | { value: '417', label: 'Expectation Failed' }, 72 | { value: '418', label: 'Im a teapot' }, 73 | { value: '420', label: 'Enhance Your Calm' }, 74 | { value: '422', label: 'Unprocessable Entity', highlight: true }, 75 | { value: '423', label: 'Locked' }, 76 | { value: '424', label: 'Failed Dependency' }, 77 | { value: '425', label: 'Unordered Collection' }, 78 | { value: '426', label: 'Upgrade Required' }, 79 | { value: '428', label: 'Precondition Required' }, 80 | { value: '429', label: 'Too Many Requests' }, 81 | { value: '431', label: 'Request Header Fields Too Large' }, 82 | { value: '444', label: 'No Response' }, 83 | { value: '449', label: 'Retry With' }, 84 | { value: '450', label: 'Blocked by Windows Parental Controls' }, 85 | { value: '499', label: 'Client Closed Request' }, 86 | ], 87 | }, 88 | { 89 | label: '5xx Server errors', 90 | options: [ 91 | { value: '500', label: 'Internal Server Error', highlight: true }, 92 | { value: '501', label: 'Not Implemented' }, 93 | { value: '502', label: 'Bad Gateway' }, 94 | { value: '503', label: 'Service Unavailable', highlight: true }, 95 | { value: '504', label: 'Gateway Timeout' }, 96 | { value: '505', label: 'HTTP Version Not Supported' }, 97 | { value: '506', label: 'Variant Also Negotiates' }, 98 | { value: '507', label: 'Insufficient Storage' }, 99 | { value: '509', label: 'Bandwidth Limit Exceeded' }, 100 | { value: '510', label: 'Not Extended' }, 101 | ], 102 | }, 103 | ]; 104 | -------------------------------------------------------------------------------- /client/src/components/SelectHttpStatusCode/styles.css: -------------------------------------------------------------------------------- 1 | .select--dark { 2 | font-weight: bolder; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/components/TextareaCodeEditor/TextareaCodeEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FieldProps } from 'formik'; 3 | import TextareaAutosize from 'react-textarea-autosize'; 4 | 5 | const placeholder = JSON.stringify( 6 | { 7 | identity: { 8 | id: 'b06cd03f-75d0-413a-b94b-35e155444d70', 9 | login: 'John Doe', 10 | }, 11 | permissions: { roles: ['moderator'] }, 12 | }, 13 | null, 14 | 2 15 | ); 16 | 17 | const TextareaCodeEditor = ({ field, form, ...props }: FieldProps & { label: string }) => { 18 | return ( 19 | 27 | ); 28 | }; 29 | 30 | export default TextareaCodeEditor; 31 | -------------------------------------------------------------------------------- /client/src/components/TextareaHeaders/TextareaHeaders.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FieldProps } from 'formik'; 3 | import TextareaAutosize from 'react-textarea-autosize'; 4 | 5 | const placeholder = JSON.stringify({ 'X-Foo-Bar': 'Hello World' }, null, 2); 6 | 7 | const TextareaHeaders = ({ field, form: { touched, errors }, ...props }: FieldProps & { label: string }) => { 8 | let classNameError = !!errors[field.name] && !!touched[field.name] ? 'input--error' : ''; 9 | 10 | return ( 11 | 19 | ); 20 | }; 21 | 22 | export default TextareaHeaders; 23 | -------------------------------------------------------------------------------- /client/src/components/TrackPageView/TrackPageView.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | import GA from '../../services/Analytics/GA'; 4 | 5 | export default () => { 6 | const { pathname } = useLocation(); 7 | 8 | useEffect(() => { 9 | GA.pageView(pathname); 10 | }, [pathname]); 11 | 12 | return null; 13 | }; 14 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | .textarea--code { 2 | font-family: 'Source Code Pro'; 3 | font-size: 12px; 4 | line-height: normal; 5 | padding: 10px; 6 | transition: none; 7 | -webkit-transition: none; 8 | } 9 | 10 | .textarea--code::placeholder { 11 | font-family: 'Source Code Pro'; 12 | font-size: 12px; 13 | line-height: normal; 14 | } 15 | 16 | input.input--error, 17 | textarea.input--error { 18 | border-color: #dc3545; 19 | } 20 | 21 | .user-select-all { 22 | -webkit-user-select: all !important; 23 | -moz-user-select: all !important; 24 | -ms-user-select: all !important; 25 | user-select: all !important; 26 | } 27 | 28 | .logo-link:hover img { 29 | filter: invert(25%) sepia(46%) saturate(894%) hue-rotate(161deg) brightness(94%) contrast(88%); 30 | -webkit-filter: invert(25%) sepia(46%) saturate(894%) hue-rotate(161deg) brightness(94%) contrast(88%); 31 | } 32 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | import * as React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { Provider } from 'react-redux'; 6 | 7 | import Routing from './modules/routing/Routing'; 8 | import { store } from './redux/store'; 9 | import * as serviceWorker from './serviceWorker'; 10 | import GA from './services/Analytics/GA'; 11 | 12 | GA.initialize(); 13 | 14 | ReactDOM.render( 15 | 16 | 17 | , 18 | document.getElementById('root') 19 | ); 20 | 21 | // If you want your app to work offline and load faster, you can change 22 | // unregister() to register() below. Note this comes with some pitfalls. 23 | // Learn more about service workers: https://bit.ly/CRA-PWA 24 | serviceWorker.unregister(); 25 | -------------------------------------------------------------------------------- /client/src/modules/about/About.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ProfilePicture from './components/ProfilePicture'; 4 | 5 | export default () => ( 6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 |
15 |

Julien Lafont

16 | 17 | CTO  18 | 19 | TabMo 20 | 21 | 22 |
23 |

Web alchemist and lazy Open Source contributor.

24 |

25 | 26 | buy me a coffee 27 | 28 |

29 | 46 |
47 |
48 |
49 |
50 |
51 | ); 52 | -------------------------------------------------------------------------------- /client/src/modules/about/components/ProfilePicture.css: -------------------------------------------------------------------------------- 1 | .profilePicture-round-double { 2 | border-radius: 50px 0 50px 0px; 3 | } 4 | 5 | .profilePicture-shadow-leftBottom { 6 | box-shadow: -10px 10px 40px rgba(0, 0, 0, 0.4); 7 | } 8 | -------------------------------------------------------------------------------- /client/src/modules/about/components/ProfilePicture.tsx: -------------------------------------------------------------------------------- 1 | import './ProfilePicture.css'; 2 | 3 | import React from 'react'; 4 | 5 | import profile from './assets/profile.jpg'; 6 | 7 | export default () => ( 8 | profile 9 | ); 10 | -------------------------------------------------------------------------------- /client/src/modules/about/components/assets/profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/src/modules/about/components/assets/profile.jpg -------------------------------------------------------------------------------- /client/src/modules/designer/Designer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import DesignerTitle from './components/DesignerTitle'; 4 | import NewMockFeatures from './components/NewMockFeatures'; 5 | import NewMockForm from './form/NewMockForm'; 6 | 7 | export default () => { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/modules/designer/NewMockConfirmation.tsx: -------------------------------------------------------------------------------- 1 | import './styles.css'; 2 | 3 | import React, { useState } from 'react'; 4 | import CopyToClipboard from 'react-copy-to-clipboard'; 5 | import { useSelector } from 'react-redux'; 6 | import { Redirect } from 'react-router-dom'; 7 | 8 | import { faCopy as iconCopy } from '@fortawesome/free-solid-svg-icons'; 9 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 10 | 11 | import { selectLatestMock, selectCountMocks } from '../../redux/mocks/slice'; 12 | import DesignerTitle from './components/DesignerTitle'; 13 | import NewMockFeatures from './components/NewMockFeatures'; 14 | import Pub from './components/Pub'; 15 | import SponsoConfirmation from '../sponso-abstract/SponsoConfirmation'; 16 | 17 | const NewMockConfirmation = () => { 18 | const [copied, setCopied] = useState(0); 19 | const mock = useSelector(selectLatestMock); 20 | const nbMocks = useSelector(selectCountMocks); 21 | 22 | const isPromotingActivated = process.env.REACT_APP_SHOW_PROMOTING_PANEL === 'true'; 23 | const isSponsoringActivated = process.env.REACT_APP_SHOW_SPONSORING === 'true'; 24 | 25 | if (!mock) { 26 | return ; 27 | } 28 | 29 | return ( 30 | <> 31 | 32 | 33 | {/* Display self-advertising panel only if promoting is activated and sponsoring is not activated */} 34 | {!isSponsoringActivated && isPromotingActivated && (nbMocks === 2 || nbMocks % 4 === 0) && } 35 | 36 | {/* Display sponsoring panel if sponsoring is enabled */} 37 | {isSponsoringActivated && (nbMocks === 2 || nbMocks % 4 === 0) && } 38 | 39 |
40 |
41 |
42 |
43 |

{copied > 0 ? "Link copied, You're now ready!" : 'Your mock is ready!'}

44 | 45 |
46 |

47 | Mock URL 48 | setCopied(1)}> 49 | 50 | 51 |

52 | 53 |
54 |                   
55 |                     {mock.link}
56 |                   
57 |                 
58 |
59 | 60 | 61 | Secret delete link 62 | 63 | 64 | 65 | 66 | 67 |
68 |                 {mock.deleteLink}
69 |               
70 |
71 |
72 |
73 |
74 | 75 | 76 | ); 77 | }; 78 | 79 | export default NewMockConfirmation; 80 | -------------------------------------------------------------------------------- /client/src/modules/designer/assets/clem-onojeghuo-DoA2duXyzRM-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/src/modules/designer/assets/clem-onojeghuo-DoA2duXyzRM-unsplash.jpg -------------------------------------------------------------------------------- /client/src/modules/designer/components/DesignerTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |
5 |
6 |

Design your mock

7 |
8 |
9 | ); 10 | -------------------------------------------------------------------------------- /client/src/modules/designer/components/NewMockFeatures.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |
5 |
6 |
7 |
8 |
9 |
JSONP support
10 |

11 | Add ?callback=myfunction to your mocky URL to transform the response into a JSONP response. 12 |
13 | 18 | See in action 19 | 20 |

21 |
22 |
23 | 24 |
25 |
26 |
CORS
27 |

28 | CORS Preflight requests are automatically accepted from any origin. You just have call the mock with an{' '} 29 | Origin header. 30 |
31 | 36 | See in action 37 | 38 |

39 |
40 |
41 |
42 |
43 |
Arbitrary Delay
44 |

45 | Add the ?mocky-delay=100ms paramater to your mocky URL to delay the response. Maximum delay: 46 | 60s. 47 |
48 | 53 | See in action 54 | 55 |
56 | 61 | Allowed values 62 | 63 |

64 |
65 |
66 |
67 |
68 |
Limitation
69 |

70 | You're allowed to call your mocky up to 100 times per seconds. Call it at least once in the year to 71 | postpone automatic deletion. 72 |

73 |
74 |
75 |
76 |
77 |
78 | ); 79 | -------------------------------------------------------------------------------- /client/src/modules/designer/components/Pub.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import coffee from '../assets/clem-onojeghuo-DoA2duXyzRM-unsplash.jpg'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { faTwitter as iconTwitter } from '@fortawesome/free-brands-svg-icons'; 6 | import { faBeer as iconCoffee } from '@fortawesome/free-solid-svg-icons'; 7 | 8 | export default () => ( 9 |
10 |
11 |
12 |
13 | woman 14 |
15 |
16 |

How to support Mocky?

17 |
18 |
Talk about us
19 |

20 | Give a shoot on twitter! Share how Mocky helps you in your daily life, how it improves your productivity. 21 |

22 | 23 |
Donate to cover operating costs
24 |

25 | Mocky is a free and open-source service, but not a costless service. All contributions to cover operating 26 | costs are welcome. 27 |

28 |
29 |
30 |
31 |
32 |

 

33 |
34 |

35 | 41 | 42 | 43 |   Give a shout 44 | 45 | 46 |

47 |
48 |
49 |

50 | 56 | 57 | 58 |   Buy me a beer 59 | 60 | 61 |

62 |
63 |
64 |
65 |
66 |
67 | ); 68 | -------------------------------------------------------------------------------- /client/src/modules/designer/form/CleanConfirmationOnSubmit.tsx: -------------------------------------------------------------------------------- 1 | import { useFormikContext } from 'formik'; 2 | import { useEffect } from 'react'; 3 | import { useDispatch } from 'react-redux'; 4 | 5 | import { clearNew } from '../../../redux/mocks/slice'; 6 | 7 | const CleanConfirmationOnSubmit = () => { 8 | const dispatch = useDispatch(); 9 | 10 | const { isValidating } = useFormikContext(); 11 | 12 | // Clean the confirmation message when the form is re-submited 13 | useEffect(() => { 14 | if (isValidating) { 15 | dispatch(clearNew()); 16 | } 17 | }, [isValidating, dispatch]); 18 | 19 | return null; 20 | }; 21 | 22 | export default CleanConfirmationOnSubmit; 23 | -------------------------------------------------------------------------------- /client/src/modules/designer/form/NewMockForm.tsx: -------------------------------------------------------------------------------- 1 | import { withFormik } from 'formik'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { store } from '../../../redux/mocks/slice'; 5 | import MockyAPI from '../../../services/MockyAPI/MockyAPI'; 6 | import { initialState, newMockValidationSchema } from './models'; 7 | import NewMockFormView from './NewMockFormView'; 8 | import { NewMockFormProps, NewMockFormValues } from './types'; 9 | import { withRouter, RouteComponentProps } from 'react-router-dom'; 10 | import GA from '../../../services/Analytics/GA'; 11 | 12 | const mapDispatchToProps = { store }; 13 | 14 | const form = withFormik({ 15 | validationSchema: newMockValidationSchema, 16 | validateOnBlur: true, 17 | validateOnChange: false, 18 | mapPropsToValues: (initialProps) => initialState, 19 | handleSubmit: async (values, { props }) => { 20 | const result = await MockyAPI.create(values); 21 | if (result !== undefined) { 22 | GA.event('mock', 'create'); 23 | props.store(result); 24 | props.history.push('/design/confirmation'); 25 | } 26 | }, 27 | })(NewMockFormView); 28 | 29 | const NewMockForm = withRouter(connect(null, mapDispatchToProps)(form)); 30 | 31 | export default NewMockForm; 32 | -------------------------------------------------------------------------------- /client/src/modules/designer/form/models.ts: -------------------------------------------------------------------------------- 1 | import * as Yup from 'yup'; 2 | 3 | import { NewMockFormValues } from './types'; 4 | 5 | export const initialState: NewMockFormValues = { 6 | status: 200, 7 | charset: 'UTF-8', 8 | contentType: 'application/json', 9 | headers: '', 10 | body: '', 11 | secret: '', 12 | name: '', 13 | expiration: 'never', 14 | }; 15 | 16 | // Check if the header field is a "basic" object with string key and string value 17 | // The key must be an alphanumric key 18 | const validateJsonHeaders = { 19 | name: 'isJsonHeaders', 20 | exclusive: false, 21 | /* eslint no-template-curly-in-string: 0 */ 22 | message: '${path} must be a valid JSON object', 23 | test: function (this: Yup.TestContext, value: string) { 24 | if (value === undefined) return true; 25 | try { 26 | const json = JSON.parse(value); 27 | if (typeof json !== 'object') return this.createError({ message: 'Headers must be a JSON object' }); 28 | if (!Object.keys(json).every((x) => typeof x === 'string' && x.match(/^[a-zA-Z0-9-_]*$/))) 29 | return this.createError({ message: 'Headers key must be simple string (allowed characters: a-z A-Z 0-9 _ -)' }); 30 | if (!Object.values(json).every((x) => typeof x === 'string')) 31 | return this.createError({ message: 'Headers values must be a string' }); 32 | return true; 33 | } catch (e) { 34 | return this.createError({ message: 'Headers must be a JSON object' }); 35 | } 36 | }, 37 | }; 38 | 39 | export const newMockValidationSchema = Yup.object({ 40 | status: Yup.number().required('Please select the status code of your mock.'), 41 | contentType: Yup.string().max(200).required('Please define the content-type of your mock.'), 42 | charset: Yup.string().max(50).required('Please select the charset of your mock.'), 43 | headers: Yup.string().trim().max(1000).test(validateJsonHeaders).optional(), 44 | name: Yup.string().max(100).optional(), 45 | body: Yup.string().trim().ensure().max(1000000).optional(), 46 | secret: Yup.string().max(64).optional(), 47 | }); 48 | -------------------------------------------------------------------------------- /client/src/modules/designer/form/styles.css: -------------------------------------------------------------------------------- 1 | .expiration-select select { 2 | font-size: 0.98em; 3 | padding-top: 0; 4 | padding-bottom: 0; 5 | color: #333; 6 | } 7 | 8 | small.form-text, 9 | span.form-text { 10 | line-height: 18px; 11 | } 12 | 13 | .table td { 14 | vertical-align: middle; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/modules/designer/form/types.ts: -------------------------------------------------------------------------------- 1 | export interface NewMockFormValues { 2 | status: number; 3 | contentType: string; 4 | charset: string; 5 | headers?: string; 6 | body: string; 7 | secret?: string; 8 | name?: string; 9 | expiration: string; 10 | } 11 | 12 | export interface OtherProps {} 13 | 14 | export interface NewMockFormProps {} 15 | -------------------------------------------------------------------------------- /client/src/modules/designer/styles.css: -------------------------------------------------------------------------------- 1 | .bg--primary pre a:not(.btn) { 2 | color: #2374ab; 3 | } 4 | 5 | .iconMocky--main { 6 | margin-left: 10px; 7 | cursor: pointer; 8 | } 9 | 10 | .iconMocky--sec { 11 | margin-left: 5px; 12 | cursor: pointer; 13 | } 14 | 15 | .btn-cta-pub { 16 | width: 215px; 17 | } 18 | 19 | .btn-cta-pub svg { 20 | vertical-align: -0.325em; 21 | } 22 | -------------------------------------------------------------------------------- /client/src/modules/faq/Faq.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 | <> 5 |
6 |
7 |
8 |
9 |

FAQ

10 |

11 | What you always wanted to know about Mocky 12 |

13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 |
21 |
22 |
23 |

General

24 |
25 |
26 |
27 |
28 |
What is Mocky?
29 |

30 | Mocky is a simple app which allows to generate custom HTTP responses. It's helpful when you have to 31 | request a build-in-progress WS, when you want to mock the backend response in a singleapp, or when you 32 | want to test your WS client. 33 |

34 |
35 |
36 |
Is it really free?
37 |

38 | Yes, mocky is really and totally free for you (but not for me{' '} 39 | 40 | 🙃 41 | {' '} 42 | ). If you feel like it, I accept{' '} 43 | 44 | donations 45 | {' '} 46 | to help me finance the service! 47 |

48 |
49 |
50 |
51 |
52 |
How many mocks can I store? How long do they last?
53 |

54 | You can create as many mocks as you want, and they will last forever. There is just one rule: call it at 55 | least once every year to keep it alive! 56 |

57 |
58 |
59 |
What is mocky SLA?
60 |

61 | Mocky do NOT commit for any Service-level agreement. Everything is done to provide the best 62 | service quality, but I don't guarantee the longevity or the stability of the application. If you need 63 | guarantees, you have to host your own Mocky instance. 64 |

65 |
66 |
67 |
68 |
69 |
70 | 71 |
72 |
73 |
74 |
75 |
76 |

Privacy

77 |
78 |
79 |
80 |
81 |
Will my mock be private?
82 |

Your mock is only accessible from its unique and private URL. Keep it secret!

83 |
84 |
85 |
Is it allowed to store Personal Information?
86 |

87 | Absolutely NO! This website is hosted in Europe, where the GDPR regulate the processing of 88 | Personal Identifiable Information. It's strictly forbidden to store PII without the right declarations. 89 |

90 |
91 |
92 |
93 |
94 |
Are my data encrypted at rest?
95 |

96 | No. Mocky administrators have access to all mocks and have the right to delete any mock 97 | without warning or restriction. 98 |

99 |
100 |
101 |
Could you delete a mock for me please?
102 |

103 | I don't guarantee to process your request, but send me a direct message on twitter. 104 |

105 |
106 |
107 |
108 |
109 |
110 | 111 | ); 112 | -------------------------------------------------------------------------------- /client/src/modules/home/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import CallToActionDesignMock from './components/CallToActionDesignMock'; 4 | import Features from './components/Features'; 5 | import HeadTitle from './components/HeadTitle'; 6 | import WhatIsMocky from './components/WhatIsMocky'; 7 | 8 | const Home = () => ( 9 | <> 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | export default Home; 17 | -------------------------------------------------------------------------------- /client/src/modules/home/components/CallToActionDesignMock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | export default () => ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |

No signup

13 |
14 |
15 |

16 | Start designing your mock 17 |
18 |

19 |
20 |
21 | 22 | NEW MOCK 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | ); 32 | -------------------------------------------------------------------------------- /client/src/modules/home/components/Features.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | export default () => ( 5 |
6 |
7 |
8 |
9 |
10 | 11 |
Free & Unlimited
12 |

13 | Mocky is free to use, no ads, no hidden subscriptions or service limits. Your mocks will be available{' '} 14 | forever if you call it at least one time per year, but without any{' '} 15 | guarantee. 16 |

17 |
18 |
19 |
20 |
21 | 22 |
Total control
23 |

24 | New in Mocky, you can now update or delete your mocks at any time. 25 |
26 | The next release will go further and offer you request inspector and cloud-based mock management. 27 |

28 |
29 |
30 |
31 |
32 | 33 |
Developer Friendly
34 |

35 | Mocky is compatible with JS, Mobile and Server applications, featuring CORS, JSONP and GZIP responses. 36 |
37 | No authentication, just call it! 38 |

39 |
40 |
41 |
42 |
43 | 44 |
Open Source 
45 |

46 | Mocky is distributed with Apache 2 licence on{' '} 47 | 48 | Github 49 | 50 | . Community contributions are welcome! Ready-to-use distributions will be available to host your own Mocky 51 | instance. 52 |

53 |
54 |
55 |
56 |
57 |
58 | ); 59 | -------------------------------------------------------------------------------- /client/src/modules/home/components/HeadTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typed from 'react-typed'; 3 | 4 | export default () => ( 5 |
6 |
7 |
8 |
9 |

Mocky

10 |

11 | 12 |

13 |
14 |
15 |
16 |
17 | ); 18 | -------------------------------------------------------------------------------- /client/src/modules/home/components/WhatIsMocky.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | import code from './assets/carbon.svg'; 5 | 6 | export default () => ( 7 |
8 |
9 |
10 |
11 | curl -X PUT https://www.mocky.io/v2/5185415ba171ea3a00704eed 12 |
13 |
14 |
15 |

API Mocks for Free

16 |

17 | Don't wait for the backend to be ready, generate custom API responses with Mocky and start working on your 18 | application straightaway 19 |

20 | Start designing your mock » 21 |
22 |
23 |
24 |
25 |
26 | ); 27 | -------------------------------------------------------------------------------- /client/src/modules/maintenance/Maintenance.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import background from './assets/inner-6.jpg'; 4 | 5 | export default () => ( 6 |
7 |
8 |
15 | background 16 |
17 |
18 |
19 |
20 |

We're currently making some improvements — Check back soon.

21 |
22 |
23 |
24 |
25 |
26 | ); 27 | -------------------------------------------------------------------------------- /client/src/modules/maintenance/assets/inner-6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/src/modules/maintenance/assets/inner-6.jpg -------------------------------------------------------------------------------- /client/src/modules/manager/Manager.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { selectAllMocks } from '../../redux/mocks/slice'; 5 | import EmptyPlaceholder from './table/EmptyPlaceholder'; 6 | import ManagerTable from './table/ManagerTable'; 7 | import ManagerTitle from './components/ManagerTitle'; 8 | import './styles.css'; 9 | 10 | const Manager = () => { 11 | const mocks = useSelector(selectAllMocks); 12 | const isEmpty = mocks.length === 0; 13 | 14 | return ( 15 | <> 16 | 17 | 18 | {isEmpty && } 19 | 20 | {!isEmpty && } 21 | 22 | ); 23 | }; 24 | 25 | export default Manager; 26 | -------------------------------------------------------------------------------- /client/src/modules/manager/components/DeleteMockInformation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Moment from 'react-moment'; 3 | 4 | import { MockStored } from '../../../redux/mocks/types'; 5 | 6 | export default ({ id, secret, mock }: { id: string; secret: string; mock?: MockStored }) => ( 7 |
 8 |     ID: {id}
 9 |     
10 | SECRET: {secret} 11 |
12 | {mock && ( 13 | <> 14 |
15 | NAME: {mock.name ?? 'Unamed mock'} 16 |
17 | CREATION DATE: 18 | 19 | )} 20 |
21 | ); 22 | -------------------------------------------------------------------------------- /client/src/modules/manager/components/ManagerTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |
5 |
6 |
7 |
8 |

Manage your mocks

9 |
10 |
11 |
12 |
13 | ); 14 | -------------------------------------------------------------------------------- /client/src/modules/manager/components/MockGone.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | export default () => ( 5 | <> 6 |

7 | This mock is gone{' '} 8 | 9 | 🥺 10 | 11 |

12 | 13 | 14 | Go back 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /client/src/modules/manager/components/SearchingMockLoader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { default as Loading } from 'react-loader-spinner'; 4 | 5 | import 'react-loader-spinner/dist/loader/css/react-spinner-loader.css'; 6 | 7 | export default () => ; 8 | -------------------------------------------------------------------------------- /client/src/modules/manager/delete/DeleteSuccessful.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ManagerTitle from '../components/ManagerTitle'; 4 | import MockGone from '../components/MockGone'; 5 | 6 | export default () => { 7 | return ( 8 | <> 9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |

Mock Deletion

17 | 18 |
19 |
20 |
21 |
22 |
23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/modules/manager/delete/DeletionApproval.tsx: -------------------------------------------------------------------------------- 1 | import '../styles.css'; 2 | 3 | import React, { useState } from 'react'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | import { useHistory, useParams } from 'react-router-dom'; 6 | import { useAsync } from 'react-use'; 7 | 8 | import { remove, selectMockById } from '../../../redux/mocks/slice'; 9 | import MockyAPI from '../../../services/MockyAPI/MockyAPI'; 10 | import DeleteMockInformation from '../components/DeleteMockInformation'; 11 | import ManagerTitle from '../components/ManagerTitle'; 12 | import MockGone from '../components/MockGone'; 13 | import SearchingMockLoader from '../components/SearchingMockLoader'; 14 | import { DeleteMockParams } from '../types'; 15 | import GA from '../../../services/Analytics/GA'; 16 | 17 | const DeletionApproval = () => { 18 | const { id, secret }: DeleteMockParams = useParams(); 19 | const dispatch = useDispatch(); 20 | const history = useHistory(); 21 | 22 | const [deleting, setDeleting] = useState(false); 23 | const [deleted, setDeleted] = useState(false); 24 | 25 | // Check if the mock exist in the store 26 | const mock = useSelector(selectMockById(id, secret)); 27 | 28 | const state = useAsync(async () => { 29 | return MockyAPI.check({ id, secret }); 30 | }, [id, secret]); 31 | 32 | const triggerDelete = async () => { 33 | setDeleting(true); 34 | 35 | // Delete from the store 36 | dispatch(remove(id)); 37 | 38 | GA.event('mock', 'delete'); 39 | 40 | // Try delete from the API 41 | const result = await MockyAPI.delete({ id, secret }); 42 | 43 | // Mock deleted by the API -> confirmation 44 | if (result) { 45 | dispatch(remove(id)); 46 | setDeleted(true); 47 | } else { 48 | history.push('/manage/delete/done'); 49 | } 50 | }; 51 | 52 | return ( 53 | <> 54 | 55 | 56 |
57 |
58 |
59 |
60 |
61 |

Mock Deletion

62 | 63 | {(deleted && ) || 64 | (state.loading ? ( 65 | 66 | ) : state.error ? ( 67 |

Something got wrong...

68 | ) : state.value ? ( 69 | <> 70 |

You're about to definitively delete the following mock, are you sure?

71 | 72 | 80 | 81 | ) : ( 82 |

This mock don't exist (anymore).

83 | ))} 84 |
85 |
86 |
87 |
88 |
89 | 90 | ); 91 | }; 92 | 93 | export default DeletionApproval; 94 | -------------------------------------------------------------------------------- /client/src/modules/manager/styles.css: -------------------------------------------------------------------------------- 1 | pre.resume { 2 | margin: 5px; 3 | padding: 2px; 4 | text-align: left; 5 | font-family: 'Source Code Pro'; 6 | font-size: 11px; 7 | line-height: normal; 8 | max-height: 200px; 9 | width: 500px; 10 | word-break: break-all; 11 | } 12 | 13 | a.icon-delete { 14 | color: #dc3545; 15 | } 16 | 17 | span.icon-edit { 18 | color: #6c757d; 19 | opacity: 0.5; 20 | } 21 | 22 | button.btn--confirmation { 23 | background: #dc3545 !important; 24 | color: #fff !important; 25 | } 26 | -------------------------------------------------------------------------------- /client/src/modules/manager/table/EmptyPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import background from './assets/sand-bg.jpg'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | export default () => ( 6 |
7 |
14 | background 15 |
16 |
17 |
18 |
19 |

Your mocky repository is empty...

20 | 21 | Create your first mock! 22 | 23 |
24 |
25 |
26 |
27 | ); 28 | -------------------------------------------------------------------------------- /client/src/modules/manager/table/ManagerTable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Moment from 'react-moment'; 3 | import wrap from 'word-wrap'; 4 | 5 | import { 6 | faEdit as iconEdit, 7 | faExternalLinkAlt as iconOpen, 8 | faTrash as iconDelete, 9 | } from '@fortawesome/free-solid-svg-icons'; 10 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 11 | 12 | import { MockStored } from '../../../redux/mocks/types'; 13 | import { NavLink } from 'react-router-dom'; 14 | 15 | const ManagerTable = (props: { mocks: MockStored[] }) => ( 16 |
17 |
18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {props.mocks.map((mock) => { 31 | const name = (mock.name && {wrap(mock.name, { width: 30, cut: true })}) || ( 32 | {mock.id} 33 | ); 34 | 35 | const resume = ( 36 | <> 37 | {mock.status}  38 | {mock.contentType}  39 | {mock.charset} 40 | {(mock.content && ( 41 |
 42 |                         {/**/}
 43 |                         {(mock.content ?? '').substr(0, 2000).trim()}
 44 |                       
45 | )) || ( 46 | <> 47 |
48 | NO CONTENT 49 | 50 | )} 51 | 52 | ); 53 | 54 | const createdAt = ( 55 | 56 | Created on 57 |
58 | 59 |
60 | ); 61 | 62 | const expireAt = mock.expireAt && ( 63 | <> 64 |
65 | 66 | Expire 67 | 68 | 69 | ); 70 | 71 | const openLink = ( 72 | 73 | 74 | 75 | ); 76 | 77 | const deleteLink = ( 78 | 79 | 80 | 81 | ); 82 | 83 | const editLink = ( 84 | 85 | 86 | 87 | ); 88 | 89 | return ( 90 | 91 | 92 | 93 | 97 | 102 | 103 | ); 104 | })} 105 | 106 |
NameDescriptionDateActions
{name}{resume} 94 | {createdAt} 95 | {expireAt} 96 | 98 | {openLink}    99 | {editLink}    100 | {deleteLink} 101 |
107 | 108 |
109 |
110 | Warning: This data is stored on your computer. It will be lost if you clean your 111 | browser cache (local-storage). 112 |
113 |
114 |
115 |
116 |
117 |
118 | ); 119 | 120 | export default ManagerTable; 121 | -------------------------------------------------------------------------------- /client/src/modules/manager/table/assets/sand-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/src/modules/manager/table/assets/sand-bg.jpg -------------------------------------------------------------------------------- /client/src/modules/manager/types.ts: -------------------------------------------------------------------------------- 1 | export interface DeleteMockParams { 2 | id: string; 3 | secret: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/modules/policies/CookiePolicy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './styles.css'; 4 | 5 | export default () => ( 6 | <> 7 |
8 |
9 |
10 |
11 |

Cookie Policy

12 |
13 |
14 |
15 |
16 | 17 |
18 |
19 |
20 |
21 |
22 |

This is the Cookie Policy for Mocky, accessible from https://www.mocky.io

23 | 24 |

25 | What Are Cookies 26 |

27 | 28 |

29 | As is common practice with almost all professional websites this site uses cookies, which are tiny files 30 | that are downloaded to your computer, to improve your experience. This page describes what information 31 | they gather, how we use it and why we sometimes need to store these cookies. We will also share how you 32 | can prevent these cookies from being stored however this may downgrade or 'break' certain elements of 33 | the sites functionality. 34 |

35 | 36 |

37 | For more general information on cookies, please read{' '} 38 | "What Are Cookies". 39 |

40 | 41 |

42 | How We Use Cookies 43 |

44 | 45 |

46 | We use cookies for a variety of reasons detailed below. Unfortunately in most cases there are no 47 | industry standard options for disabling cookies without completely disabling the functionality and 48 | features they add to this site. It is recommended that you leave on all cookies if you are not sure 49 | whether you need them or not in case they are used to provide a service that you use. 50 |

51 | 52 |

53 | Disabling Cookies 54 |

55 | 56 |

57 | You can prevent the setting of cookies by adjusting the settings on your browser (see your browser Help 58 | for how to do this). Be aware that disabling cookies will affect the functionality of this and many 59 | other websites that you visit. Disabling cookies will usually result in also disabling certain 60 | functionality and features of the this site. Therefore it is recommended that you do not disable 61 | cookies. 62 |

63 | 64 |

65 | The Cookies We Set 66 |

67 | 68 |
    69 |
  • 70 | Site preferences cookies 71 |

    72 | In order to provide you with a great experience on this site we provide the functionality to set 73 | your preferences for how this site runs when you use it. In order to remember your preferences we 74 | need to set cookies so that this information can be called whenever you interact with a page is 75 | affected by your preferences. 76 |

    77 |
  • 78 |
79 | 80 |

81 | Third Party Cookies 82 |

83 | 84 |

85 | In some special cases we also use cookies provided by trusted third parties. The following section 86 | details which third party cookies you might encounter through this site. 87 |

88 | 89 |
    90 |
  • 91 | Google Analytics 92 |

    93 | This site uses Google Analytics which is one of the most widespread and trusted analytics solution 94 | on the web for helping us to understand how you use the site and ways that we can improve your 95 | experience. These cookies may track things such as how long you spend on the site and the pages that 96 | you visit so we can continue to produce engaging content. 97 |

    98 |

    For more information on Google Analytics cookies, see the official Google Analytics page.

    99 |
  • 100 |
101 |
102 |
103 |
104 |
105 |
106 | 107 | ); 108 | -------------------------------------------------------------------------------- /client/src/modules/policies/styles.css: -------------------------------------------------------------------------------- 1 | .policy li { 2 | margin-left: 30px; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/modules/routing/Page404.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | export default () => ( 5 |
6 |
7 |
8 |
9 |

404

10 |

The page you were looking for was not found.

11 | Go back to home page 12 |
13 |
14 |
15 |
16 | ); 17 | -------------------------------------------------------------------------------- /client/src/modules/routing/Page500.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | export default () => ( 5 |
6 |
7 |
8 |
9 |

500

10 |

An unexpected error has occured preventing the page from loading.

11 | Go back to home page 12 |
13 |
14 |
15 |
16 | ); 17 | -------------------------------------------------------------------------------- /client/src/modules/routing/Routing.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 3 | 4 | import CookieNotification from '../../components/CookieNotification/CookieNotification'; 5 | import ErrorBoundary from '../../components/ErrorBoundary/ErrorBoundary'; 6 | import Loader from '../../components/Loader/Loader'; 7 | import ScrollToTopOnNavigationChange from '../../components/ScrollToTopOnNavigationChange/ScrollToTopOnNavigationChange'; 8 | import TrackPageView from '../../components/TrackPageView/TrackPageView'; 9 | import About from '../../modules/about/About'; 10 | import Designer from '../../modules/designer/Designer'; 11 | import Faq from '../../modules/faq/Faq'; 12 | import Home from '../../modules/home/Home'; 13 | import Page404 from '../../modules/routing/Page404'; 14 | import NewMockConfirmation from '../designer/NewMockConfirmation'; 15 | import DeleteSuccessful from '../manager/delete/DeleteSuccessful'; 16 | import DeletionApproval from '../manager/delete/DeletionApproval'; 17 | import Manager from '../manager/Manager'; 18 | import CookiePolicy from '../policies/CookiePolicy'; 19 | import Footer from '../skeleton/Footer'; 20 | import NavBar from '../skeleton/NavBar'; 21 | import Maintenance from '../maintenance/Maintenance'; 22 | 23 | const isMaintenance = process.env.REACT_APP_MAINTENANCE === 'true'; 24 | 25 | const Routing = () => ( 26 | 27 | 28 | 29 | 30 | {isMaintenance && } 31 | 32 | {!isMaintenance && ( 33 | <> 34 | 35 |
36 | 37 | }> 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 |
55 | 56 | )} 57 |
58 | ); 59 | 60 | export default Routing; 61 | -------------------------------------------------------------------------------- /client/src/modules/skeleton/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | import SponsoFooter from '../sponso-abstract/SponsoFooter'; 4 | 5 | export default () => ( 6 | 67 | ); 68 | -------------------------------------------------------------------------------- /client/src/modules/skeleton/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | import logo from './assets/logo-dark.png'; 5 | 6 | export default () => ( 7 |
8 |
9 | 33 |
34 |
35 | ); 36 | -------------------------------------------------------------------------------- /client/src/modules/skeleton/assets/logo-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/src/modules/skeleton/assets/logo-blue.png -------------------------------------------------------------------------------- /client/src/modules/skeleton/assets/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/src/modules/skeleton/assets/logo-dark.png -------------------------------------------------------------------------------- /client/src/modules/skeleton/assets/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/src/modules/skeleton/assets/logo-light.png -------------------------------------------------------------------------------- /client/src/modules/sponso-abstract/SponsoConfirmation.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import randomArray from 'unique-random-array'; 4 | 5 | import bg from './assets/chi-hang-leong-hehYcAGhbmY-unsplash.jpg'; 6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 7 | import { faExternalLinkAlt as iconLink } from '@fortawesome/free-solid-svg-icons'; 8 | import './styles.css'; 9 | 10 | import { products } from './models'; 11 | 12 | /* eslint "react/jsx-no-target-blank": ["warn", { "allowReferrer": true }] */ 13 | 14 | export default () => { 15 | const randomizedProducts = randomArray(products); 16 | const featuredProducts = [randomizedProducts(), randomizedProducts()]; 17 | 18 | return ( 19 |
20 |
21 |
22 |
23 | sponsoring 24 |
25 |
26 |

27 | Check out{' '} 28 | 29 | Abstract API 30 | 31 |

32 |

the home for modern, developer-friendly tools

33 |
34 | {featuredProducts.map((product) => ( 35 | <> 36 |
37 | {product.name}{' '} 38 | 39 | 40 | 41 |
42 |

{product.description}

43 | 44 | ))} 45 |
46 | 52 | Get your Free API Key 53 | 54 |
55 |
56 |
57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /client/src/modules/sponso-abstract/SponsoFooter.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './styles.css'; 3 | 4 | /* eslint "react/jsx-no-target-blank": ["warn", { "allowReferrer": true }] */ 5 | 6 | export default () => { 7 | const isSponsoringEnabled = process.env.REACT_APP_SHOW_SPONSORING === 'true'; 8 | if (!isSponsoringEnabled) return null; 9 | 10 | return ( 11 |
12 |
13 | Sponsored by{' '} 14 | 15 | Abstract API 16 | 17 | , the home for modern, developer-friendly tools like the{' '} 18 | 19 | IP Geolocation API 20 | 21 | ,{' '} 22 | 23 | VAT Validation & Rates API 24 | 25 | ,{' '} 26 | 27 | Public Holiday API 28 | 29 | , and more. 30 |
31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /client/src/modules/sponso-abstract/assets/chi-hang-leong-hehYcAGhbmY-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MockyAbstract/Mocky/15d0e7f10c1f6dcf2539272b3f0bf42b746b4d6a/client/src/modules/sponso-abstract/assets/chi-hang-leong-hehYcAGhbmY-unsplash.jpg -------------------------------------------------------------------------------- /client/src/modules/sponso-abstract/models.ts: -------------------------------------------------------------------------------- 1 | export const products = [ 2 | { 3 | name: 'IP Geolocation API', 4 | url: 'https://www.abstractapi.com/ip-geolocation-api', 5 | description: 'Get the location of any IP with a word-class API serving city, region, country and lat/long data.', 6 | }, 7 | { 8 | name: 'VAT Validation & Rates API', 9 | url: 'https://www.abstractapi.com/vat-validation-rates-api', 10 | description: 11 | 'Stay compliant with our simple, reliable, and powerful API for all your domestic and cross-border sales.', 12 | }, 13 | { 14 | name: 'Email Validation and Verification API', 15 | url: 'https://www.abstractapi.com/email-verification-validation-api', 16 | description: 17 | "Improve your delivery rate and clean your email lists with Abstract's industry-leading email verification API.", 18 | }, 19 | { 20 | name: 'Website Screenshot API', 21 | url: 'https://www.abstractapi.com/website-screenshot-api', 22 | description: "Transform any URL into an image with Abstract's Website Screenshot API.", 23 | }, 24 | { 25 | name: 'Exchange Rate and Currency Conversion API', 26 | url: 'https://www.abstractapi.com/exchange-rate-api', 27 | description: 28 | 'Get high quality, reliable data from from up-to-the-minute sources for 150+ currencies and 10,000+ currency pairs.', 29 | }, 30 | { 31 | name: 'Public Holidays API', 32 | url: 'https://www.abstractapi.com/holidays-api', 33 | description: 'Retrieve public holidays for hundreds of countries worldwide and for any specific year.', 34 | }, 35 | { 36 | name: 'Web Scraping API', 37 | url: 'https://www.abstractapi.com/web-scraping-api', 38 | description: 39 | 'Scrape and extract data from any website, with powerful options like proxy / browser customization, CAPTCHA handling, ad blocking, and more.', 40 | }, 41 | { 42 | name: 'Phone Number Validation and Verification API', 43 | url: 'https://www.abstractapi.com/phone-validation-api', 44 | description: 45 | "Improve your contact rate and clean your lists with Abstract's industry-leading phone number validation API.", 46 | }, 47 | { 48 | name: 'User Avatar API', 49 | url: 'https://www.abstractapi.com/user-avatar-api', 50 | description: 51 | "Create highly customizable avatar images with a person's name or initials to improve your user experience.", 52 | }, 53 | { 54 | name: 'Time, Date, & Timezone API', 55 | url: 'https://www.abstractapi.com/time-date-timezone-api', 56 | description: 57 | 'Quickly and easily get the time and date of a location or IP address, or convert the time and date of one timezone into another.', 58 | }, 59 | { 60 | name: 'Gender API', 61 | url: 'https://www.abstractapi.com/gender-api', 62 | description: 'Get the gender of more than 14 million names instantly with an easy-to-use API.', 63 | }, 64 | { 65 | name: 'Image Processing & Optimization API', 66 | url: 'https://www.abstractapi.com/image-processing-optimization-api', 67 | description: 68 | 'Manage your images programmatically with this powerful API: compress, convert, crop, resize, and more.', 69 | }, 70 | ]; 71 | -------------------------------------------------------------------------------- /client/src/modules/sponso-abstract/styles.css: -------------------------------------------------------------------------------- 1 | footer .row.sponsoring { 2 | margin: 10px 0; 3 | padding: 5px; 4 | background-color: #fafafa; 5 | border: 1px solid #ebebeb; 6 | } 7 | footer .row.sponsoring a { 8 | color: #2374ab !important; 9 | } 10 | 11 | .feature-large.sponsoring a { 12 | font-weight: normal; 13 | } 14 | 15 | .feature-large.sponsoring h3 { 16 | margin-top: 0em; 17 | margin-bottom: 0; 18 | } 19 | 20 | .feature-large.sponsoring h4 { 21 | margin-top: 0; 22 | margin-bottom: 0.6em; 23 | font-style: italic; 24 | } 25 | 26 | .feature-large.sponsoring p { 27 | margin-bottom: 0.5em; 28 | } 29 | 30 | .feature-large.sponsoring a.btn:hover { 31 | color: white; 32 | } 33 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/redux/mocks/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { RootState } from '../store'; 3 | import { MockState, MockStored } from './types'; 4 | 5 | const initialState: MockState = { 6 | last: undefined, 7 | all: [], 8 | }; 9 | 10 | export const mocksSlice = createSlice({ 11 | name: 'mocks', 12 | initialState, 13 | reducers: { 14 | store: (state, action: PayloadAction) => { 15 | state.last = action.payload; 16 | state.all.push(action.payload); 17 | }, 18 | remove: (state, action: PayloadAction) => { 19 | state.all = state.all.filter((mock) => mock.id !== action.payload); 20 | }, 21 | clearNew: (state) => { 22 | state.last = undefined; 23 | }, 24 | }, 25 | }); 26 | 27 | export const { store, remove, clearNew } = mocksSlice.actions; 28 | 29 | export const selectLatestMock = (state: RootState) => state.mocks.last; 30 | export const selectAllMocks = (state: RootState) => state.mocks.all; 31 | export const selectMockById = (id: string, secret: string) => (state: RootState) => 32 | state.mocks.all.find((mock) => mock.id === id && mock.secret === secret); 33 | export const selectCountMocks = (state: RootState) => state.mocks.all.length; 34 | 35 | export default mocksSlice.reducer; 36 | -------------------------------------------------------------------------------- /client/src/redux/mocks/types.ts: -------------------------------------------------------------------------------- 1 | export interface MockStored { 2 | name?: string; 3 | id: string; 4 | secret: string; 5 | link: string; 6 | contentType: string; 7 | status: number; 8 | content?: string; 9 | charset: string; 10 | headers?: string; 11 | deleteLink: string; 12 | createdAt: Date; 13 | expireAt?: Date; 14 | } 15 | 16 | export interface MockState { 17 | last?: MockStored; 18 | all: Array; 19 | } 20 | -------------------------------------------------------------------------------- /client/src/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'; 2 | import mocksReducer from './mocks/slice'; 3 | 4 | import { save, load } from 'redux-localstorage-simple'; 5 | 6 | export const store = configureStore({ 7 | reducer: { 8 | mocks: mocksReducer, 9 | }, 10 | // Store / Load store from local-storage 11 | middleware: [save()], 12 | preloadedState: load(), 13 | }); 14 | 15 | export type RootState = ReturnType; 16 | export type AppDispatch = typeof store.dispatch; 17 | export type AppThunk = ThunkAction>; 18 | -------------------------------------------------------------------------------- /client/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 19 | ); 20 | 21 | type Config = { 22 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 23 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 24 | }; 25 | 26 | export function register(config?: Config) { 27 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 28 | // The URL constructor is available in all browsers that support SW. 29 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 30 | if (publicUrl.origin !== window.location.origin) { 31 | // Our service worker won't work if PUBLIC_URL is on a different origin 32 | // from what our page is served on. This might happen if a CDN is used to 33 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 34 | return; 35 | } 36 | 37 | window.addEventListener('load', () => { 38 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 39 | 40 | if (isLocalhost) { 41 | // This is running on localhost. Let's check if a service worker still exists or not. 42 | checkValidServiceWorker(swUrl, config); 43 | 44 | // Add some additional logging to localhost, pointing developers to the 45 | // service worker/PWA documentation. 46 | navigator.serviceWorker.ready.then(() => { 47 | console.log( 48 | 'This web app is being served cache-first by a service ' + 49 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 50 | ); 51 | }); 52 | } else { 53 | // Is not localhost. Just register service worker 54 | registerValidSW(swUrl, config); 55 | } 56 | }); 57 | } 58 | } 59 | 60 | function registerValidSW(swUrl: string, config?: Config) { 61 | navigator.serviceWorker 62 | .register(swUrl) 63 | .then((registration) => { 64 | registration.onupdatefound = () => { 65 | const installingWorker = registration.installing; 66 | if (installingWorker == null) { 67 | return; 68 | } 69 | installingWorker.onstatechange = () => { 70 | if (installingWorker.state === 'installed') { 71 | if (navigator.serviceWorker.controller) { 72 | // At this point, the updated precached content has been fetched, 73 | // but the previous service worker will still serve the older 74 | // content until all client tabs are closed. 75 | console.log( 76 | 'New content is available and will be used when all ' + 77 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 78 | ); 79 | 80 | // Execute callback 81 | if (config && config.onUpdate) { 82 | config.onUpdate(registration); 83 | } 84 | } else { 85 | // At this point, everything has been precached. 86 | // It's the perfect time to display a 87 | // "Content is cached for offline use." message. 88 | console.log('Content is cached for offline use.'); 89 | 90 | // Execute callback 91 | if (config && config.onSuccess) { 92 | config.onSuccess(registration); 93 | } 94 | } 95 | } 96 | }; 97 | }; 98 | }) 99 | .catch((error) => { 100 | console.error('Error during service worker registration:', error); 101 | }); 102 | } 103 | 104 | function checkValidServiceWorker(swUrl: string, config?: Config) { 105 | // Check if the service worker can be found. If it can't reload the page. 106 | fetch(swUrl, { 107 | headers: { 'Service-Worker': 'script' }, 108 | }) 109 | .then((response) => { 110 | // Ensure service worker exists, and that we really are getting a JS file. 111 | const contentType = response.headers.get('content-type'); 112 | if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then((registration) => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log('No internet connection found. App is running in offline mode.'); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready 132 | .then((registration) => { 133 | registration.unregister(); 134 | }) 135 | .catch((error) => { 136 | console.error(error.message); 137 | }); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /client/src/services/Analytics/GA.ts: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga'; 2 | 3 | var initilized = false; 4 | 5 | const initialize = (debug: boolean = false) => { 6 | const trackingId = process.env.REACT_APP_GOOGLE_ANALYTICS_TRACKING_ID; 7 | 8 | if (trackingId) { 9 | initilized = true; 10 | ReactGA.initialize(trackingId, { debug: debug }); 11 | } 12 | }; 13 | 14 | const pageView = (page: string) => { 15 | if (!initilized) return; 16 | ReactGA.pageview(page); 17 | }; 18 | 19 | const event = (category: string, action: string) => { 20 | if (!initilized) return; 21 | ReactGA.event({ category, action }); 22 | }; 23 | 24 | const GA = { 25 | initialize, 26 | pageView, 27 | event, 28 | }; 29 | 30 | export default GA; 31 | -------------------------------------------------------------------------------- /client/src/services/HTTP.ts: -------------------------------------------------------------------------------- 1 | interface HttpResponse extends Response { 2 | data?: T; 3 | } 4 | 5 | async function get(url: string): Promise> { 6 | const request = { 7 | method: 'GET', 8 | mode: 'cors' as RequestMode, 9 | }; 10 | 11 | const response: HttpResponse = await fetch(url, request); 12 | response.data = await response.json(); 13 | return response; 14 | } 15 | 16 | async function post(url: string, payload: any): Promise> { 17 | const request = { 18 | headers: { 'Content-Type': 'application/json' }, 19 | method: 'POST', 20 | body: JSON.stringify(payload), 21 | mode: 'cors' as RequestMode, 22 | }; 23 | 24 | const response: HttpResponse = await fetch(url, request); 25 | response.data = await response.json(); 26 | return response; 27 | } 28 | 29 | async function _delete(url: string, payload: any): Promise { 30 | const request = { 31 | headers: { 'Content-Type': 'application/json' }, 32 | method: 'DELETE', 33 | body: JSON.stringify(payload), 34 | mode: 'cors' as RequestMode, 35 | }; 36 | 37 | const response = await fetch(url, request); 38 | return response.status === 204; 39 | } 40 | 41 | const HTTP = { 42 | get: get, 43 | post: post, 44 | delete: _delete, 45 | }; 46 | 47 | export default HTTP; 48 | -------------------------------------------------------------------------------- /client/src/services/MockyAPI/MockAPITransformer.ts: -------------------------------------------------------------------------------- 1 | import { NewMockFormValues } from '../../modules/designer/form/types'; 2 | import Random from 'randomstring'; 3 | import { MockCreated, MockCreateAPI } from './types'; 4 | import { MockStored } from '../../redux/mocks/types'; 5 | 6 | /** 7 | * Transform Mock form data to the payload expected by the API 8 | */ 9 | const formToApi = (data: NewMockFormValues): MockCreateAPI => { 10 | const headers = data.headers && data.headers !== '' ? JSON.parse(data.headers) : undefined; 11 | const secret = data.secret && data.secret !== '' ? data.secret : Random.generate(36); 12 | const content = data.body && data.body !== '' ? data.body : undefined; 13 | const name = data.name && data.name !== '' ? data.name : undefined; 14 | 15 | return { 16 | status: data.status, 17 | content: content, 18 | content_type: data.contentType, 19 | charset: data.charset, 20 | secret: secret, 21 | name: name, 22 | expiration: data.expiration, 23 | headers: headers, 24 | }; 25 | }; 26 | 27 | /** 28 | * Construct from mock API response and mock API request the data to store in the local-storage 29 | * for future usage 30 | */ 31 | const createdToStore = (created: MockCreated, data: MockCreateAPI): MockStored => { 32 | const { name, content_type, status, content, charset, headers } = data; 33 | 34 | const deleteLink = `${process.env.REACT_APP_DOMAIN}/manage/delete/${created.id}/${created.secret}`; 35 | const createdAt = new Date(); 36 | 37 | return { ...created, name, status, content, charset, headers, contentType: content_type, deleteLink, createdAt }; 38 | }; 39 | 40 | const MockyAPITransformer = { 41 | formToApi, 42 | createdToStore, 43 | }; 44 | 45 | export default MockyAPITransformer; 46 | -------------------------------------------------------------------------------- /client/src/services/MockyAPI/MockyAPI.ts: -------------------------------------------------------------------------------- 1 | import { NewMockFormValues } from '../../modules/designer/form/types'; 2 | import MockyAPITransformer from './MockAPITransformer'; 3 | import HTTP from '../HTTP'; 4 | import { MockCreated, DeleteMock } from './types'; 5 | import { MockStored } from '../../redux/mocks/types'; 6 | 7 | const URL = process.env.REACT_APP_API_URL + '/api/mock'; 8 | 9 | const create = async (data: NewMockFormValues): Promise => { 10 | const payload = MockyAPITransformer.formToApi(data); 11 | const response = await HTTP.post(URL, payload); 12 | 13 | if (!response.data) { 14 | console.error(`API Response error: ${response.body}`); 15 | return undefined; 16 | } else { 17 | return MockyAPITransformer.createdToStore(response.data, payload); 18 | } 19 | }; 20 | 21 | const _delete = async (data: DeleteMock): Promise => { 22 | return await HTTP.delete(`${URL}/${data.id}`, data); 23 | }; 24 | 25 | const check = async (data: DeleteMock): Promise => { 26 | const response = await HTTP.post(`${URL}/${data.id}/check`, data); 27 | return response.data ?? false; 28 | }; 29 | 30 | const MockyAPI = { 31 | delete: _delete, 32 | check, 33 | create, 34 | }; 35 | 36 | export default MockyAPI; 37 | -------------------------------------------------------------------------------- /client/src/services/MockyAPI/types.ts: -------------------------------------------------------------------------------- 1 | export interface MockCreated { 2 | id: string; 3 | secret: string; 4 | link: string; 5 | expireAt: Date; 6 | } 7 | 8 | export interface MockCreateAPI { 9 | status: number; 10 | content?: string; 11 | headers?: string; 12 | charset: string; 13 | content_type: string; 14 | secret: string; 15 | name?: string; 16 | expiration: string; 17 | } 18 | 19 | export interface DeleteMock { 20 | id: string; 21 | secret: string; 22 | } 23 | -------------------------------------------------------------------------------- /client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .idea 3 | -------------------------------------------------------------------------------- /server/.jvmopts: -------------------------------------------------------------------------------- 1 | -Xss4m 2 | -------------------------------------------------------------------------------- /server/.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 2.5.2 2 | align.preset = none 3 | danglingParentheses.preset = false 4 | continuationIndent.defnSite = 2 5 | maxColumn = 120 6 | newlines.source=keep 7 | newlines.alwaysBeforeElseAfterCurlyIf = false 8 | newlines.alwaysBeforeMultilineDef = false 9 | newlines.afterCurlyLambda = preserve 10 | optIn.breakChainOnFirstMethodDot = false 11 | spaces.inImportCurlyBraces = true 12 | rewrite.rules = [ 13 | AvoidInfix, 14 | RedundantParens, 15 | AsciiSortImports, 16 | SortModifiers 17 | ] -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Mocky Server 2 | 3 | ## Configuration 4 | 5 | You must define these environement variables to run Mocky server 6 | 7 | ``` 8 | MOCKY_SERVER_PORT 9 | MOCKY_DATABASE_URL 10 | MOCKY_DATABASE_USER 11 | MOCKY_DATABASE_PASSWORD 12 | MOCKY_ADMIN_PASSWORD 13 | MOCKY_CORS_DOMAIN 14 | MOCKY_ENDPOINT 15 | ``` 16 | 17 | ## Run 18 | 19 | ``` 20 | sbt run 21 | ``` 22 | 23 | ## Release a new version 24 | 25 | ``` 26 | sbt release 27 | ``` 28 | 29 | ## Build release package 30 | 31 | ``` 32 | sbt dist 33 | ``` 34 | -------------------------------------------------------------------------------- /server/build.sbt: -------------------------------------------------------------------------------- 1 | import Dependencies._ 2 | import com.typesafe.sbt.packager.MappingsHelper.directory 3 | 4 | lazy val root = (project in file(".")) 5 | .settings( 6 | name := "mocky-2020", 7 | version in ThisBuild := "3.0.3", 8 | scalaVersion := "2.13.2", 9 | maintainer := "yotsumi.fx+github@gmail.com", 10 | resolvers += "Tabmo Myget Public" at "https://www.myget.org/F/tabmo-public/maven/", 11 | libraryDependencies ++= (http4s ++ circe ++ doobie ++ pureconfig ++ log ++ cache ++ enumeratum ++ scalatest), 12 | addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.10.3"), 13 | addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") 14 | // Allow to temporary disable Fatal-Warning (can be useful during big refactoring) 15 | //scalacOptions -= "-Xfatal-warnings" 16 | ) 17 | // Package with resources 18 | .enablePlugins(JavaAppPackaging) 19 | .settings(mappings in Universal ++= directory("src/main/resources")) 20 | // Make build information available at runtime 21 | .enablePlugins(BuildInfoPlugin) 22 | .settings(Seq( 23 | buildInfoKeys := Seq[BuildInfoKey](name, version), 24 | buildInfoOptions += BuildInfoOption.BuildTime 25 | )) 26 | // Activate Integration Tests 27 | .configs(IntegrationTest) // Affect the same settings to integration test module 28 | .settings(Defaults.itSettings) // Allows to run it: tasks 29 | 30 | // Automatically reload project when build files are modified 31 | Global / onChangedBuildSource := ReloadOnSourceChanges 32 | -------------------------------------------------------------------------------- /server/project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | 5 | object Versions { 6 | val Http4sV = "0.21.4" 7 | val DoobieV = "0.9.0" 8 | val FlywayV = "6.4.2" 9 | val PostgresqlV = "42.2.12" 10 | val CirceV = "0.13.0" 11 | val CirceValidationV = "0.1.1" 12 | val PureConfigV = "0.12.3" 13 | val LogbackV = "1.2.3" 14 | val ScalaTestV = "3.1.2" 15 | val ScalaMockV = "4.4.0" 16 | val Slf4jV = "1.7.30" 17 | val TestContainerV = "0.37.0" 18 | val EnumeratumV = "1.6.1" 19 | } 20 | 21 | import Versions._ 22 | 23 | val http4s = Seq( 24 | "org.http4s" %% "http4s-blaze-server" % Http4sV, 25 | "org.http4s" %% "http4s-circe" % Http4sV, 26 | "org.http4s" %% "http4s-dsl" % Http4sV, 27 | "org.http4s" %% "http4s-blaze-client" % Http4sV % "it,test" 28 | ) 29 | 30 | val doobie = Seq( 31 | "org.tpolecat" %% "doobie-core" % DoobieV, 32 | "org.tpolecat" %% "doobie-hikari" % DoobieV, 33 | "org.tpolecat" %% "doobie-postgres" % DoobieV, 34 | "org.tpolecat" %% "doobie-postgres-circe" % DoobieV, 35 | "org.postgresql" % "postgresql" % PostgresqlV, 36 | "org.flywaydb" % "flyway-core" % FlywayV 37 | ) 38 | 39 | val circe = Seq( 40 | "io.circe" %% "circe-generic" % CirceV, 41 | "io.circe" %% "circe-literal" % CirceV % "it,test", 42 | "io.circe" %% "circe-optics" % CirceV % "it,test", 43 | "io.tabmo" %% "circe-validation-core" % CirceValidationV, 44 | "io.tabmo" %% "circe-validation-extra-rules" % CirceValidationV 45 | ) 46 | 47 | val pureconfig = Seq( 48 | "com.github.pureconfig" %% "pureconfig" % PureConfigV, 49 | "com.github.pureconfig" %% "pureconfig-cats-effect" % PureConfigV 50 | ) 51 | 52 | val scalatest = Seq( 53 | "org.scalatest" %% "scalatest" % ScalaTestV % "it,test", 54 | "org.scalamock" %% "scalamock" % ScalaMockV % Test, 55 | "com.dimafeng" %% "testcontainers-scala-scalatest" % TestContainerV % IntegrationTest, 56 | "com.dimafeng" %% "testcontainers-scala-postgresql" % TestContainerV % IntegrationTest 57 | ) 58 | 59 | val log = Seq( 60 | "ch.qos.logback" % "logback-classic" % LogbackV, 61 | "org.slf4j" % "slf4j-api" % Slf4jV, 62 | "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2", 63 | "io.chrisdavenport" %% "log4cats-slf4j" % "1.1.1" 64 | ) 65 | 66 | val cache = Seq( 67 | "com.github.blemale" %% "scaffeine" % "4.0.0" % Compile 68 | ) 69 | 70 | val enumeratum = Seq( 71 | "com.beachape" %% "enumeratum" % EnumeratumV, 72 | "com.beachape" %% "enumeratum-circe" % EnumeratumV 73 | ) 74 | 75 | } 76 | -------------------------------------------------------------------------------- /server/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.3.10 2 | -------------------------------------------------------------------------------- /server/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.1") 2 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.1") 3 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") 4 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.2") 5 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") 6 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.11") 7 | -------------------------------------------------------------------------------- /server/scripts/admin/delete.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | id="$1" 4 | 5 | curl -X DELETE -H "X-Auth-Token: $SETTINGS_ADMIN_PASSWORD" "http://localhost:8080/admin/api/$id" -v -------------------------------------------------------------------------------- /server/scripts/admin/stats.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | curl -H "X-Auth-Token: $SETTINGS_ADMIN_PASSWORD" "http://localhost:8080/admin/api/stats" -v -------------------------------------------------------------------------------- /server/scripts/api/create.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | curl -X POST \ 4 | -H 'Content-Type: application/json' \ 5 | -d '{ 6 | "content": "{\"hello\":\"world\"}", 7 | "content_type": "application/json", 8 | "charset": "UTF-8", 9 | "status": 200, 10 | "secret": "secret", 11 | "headers": { 12 | "X-FOO": "bar", 13 | "X-BAR": "foo" 14 | } 15 | }' \ 16 | "http://localhost:8080/api/mock" -------------------------------------------------------------------------------- /server/scripts/api/delete.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | id="$1" 4 | secret="$2" 5 | 6 | curl -X DELETE -d "{ \"secret\": \"$secret\" }" "http://localhost:8080/api/mock/$id" -v -------------------------------------------------------------------------------- /server/scripts/api/get.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | id="$1" 4 | 5 | curl "http://localhost:8080/api/mock/$id" -v -------------------------------------------------------------------------------- /server/scripts/api/stats.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | id="$1" 4 | 5 | curl "http://localhost:8080/api/mock/$id/stats" -v -------------------------------------------------------------------------------- /server/scripts/api/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | id="$1" 4 | secret="$2" 5 | 6 | curl -X PUT \ 7 | -H 'Content-Type: application/json' \ 8 | -d "{ 9 | \"content\": \"{\\\"hello\\\":\\\"world\\\"}\", 10 | \"content_type\": \"application/json\", 11 | \"charset\": \"UTF-8\", 12 | \"status\": 200, 13 | \"secret\": \"$secret\", 14 | \"headers\": { 15 | \"X-FOO\": \"bar\", 16 | \"X-BAR\": \"foo\" 17 | } 18 | }" \ 19 | "http://localhost:8080/api/mock/$id" -v -------------------------------------------------------------------------------- /server/scripts/play-v3.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | id="$1" 4 | 5 | curl "http://localhost:8080/v3/$id" -------------------------------------------------------------------------------- /server/src/it/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /server/src/it/scala/MockyServerSpec.scala: -------------------------------------------------------------------------------- 1 | import java.time.ZonedDateTime 2 | import scala.concurrent.duration._ 3 | import scala.concurrent.ExecutionContext 4 | import scala.concurrent.ExecutionContext.Implicits.global 5 | import scala.util.Random 6 | 7 | import cats.effect.{ ContextShift, IO, Timer } 8 | import com.dimafeng.testcontainers.{ ForAllTestContainer, PostgreSQLContainer } 9 | import io.circe.Json 10 | import io.circe.optics.JsonPath._ 11 | import org.http4s.circe._ 12 | import org.http4s.client.blaze.BlazeClientBuilder 13 | import org.http4s.util.{ CaseInsensitiveString => IString } 14 | import org.http4s.{ Header, Request, Status, Uri } 15 | import org.scalatest.OptionValues 16 | import org.scalatest.concurrent.Eventually 17 | import org.scalatest.matchers.should.Matchers 18 | import org.scalatest.time.{ Seconds, Span } 19 | import org.scalatest.wordspec.AnyWordSpec 20 | 21 | import io.mocky.HttpServer 22 | import io.mocky.config._ 23 | 24 | class MockyServerSpec extends AnyWordSpec with Matchers with OptionValues with Eventually with ForAllTestContainer { 25 | 26 | implicit private val timer: Timer[IO] = IO.timer(ExecutionContext.global) 27 | implicit private val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) 28 | implicit override val patienceConfig: PatienceConfig = PatienceConfig().copy(timeout = scaled(Span(5, Seconds))) 29 | 30 | private lazy val client = BlazeClientBuilder[IO](global).resource 31 | override val container = PostgreSQLContainer(dockerImageNameOverride = "postgres:12") 32 | 33 | private val serverPort = Random.nextInt(40000) + 1024 34 | private lazy val URL = s"http://localhost:$serverPort" 35 | 36 | override def afterStart(): Unit = { 37 | val config = Config( 38 | server = ServerConfig("localhost", serverPort), 39 | database = DatabaseConfig( 40 | driver = container.driverClassName, 41 | url = container.jdbcUrl, 42 | user = container.username, 43 | password = container.password 44 | ), 45 | settings = Settings( 46 | environment = "dev", 47 | endpoint = "https://mocky.io", 48 | mock = MockSettings(1000000, 1000), 49 | security = SecuritySettings(14), 50 | throttle = ThrottleSettings(100, 1.seconds, 10000), 51 | sleep = SleepSettings(60.seconds, "mocky-delay"), 52 | cors = CorsSettings("mocky.io"), 53 | admin = AdminSettings("X-Auth-Token", "secret") 54 | ) 55 | ) 56 | 57 | // Launch the mocky server 58 | HttpServer.createFromConfig(config).unsafeRunAsyncAndForget() 59 | 60 | // Wait for the server to be available 61 | val _ = eventually { 62 | client.use(_.statusFromUri(Uri.unsafeFromString(s"$URL/api/status"))).unsafeRunSync() shouldBe Status.Ok 63 | } 64 | } 65 | 66 | "Mocky server" should { 67 | 68 | "play an UTF8 mock" in { 69 | val id = "48e9c41b-de8c-4aeb-99a8-2f3abe3e5efa" // from V003 migration 70 | 71 | val request = Request[IO](uri = Uri.unsafeFromString(s"$URL/v3/$id")) 72 | 73 | val response = client.use(_.expect[String](request)).unsafeRunSync() 74 | response shouldBe 75 | "Dès Noël, où un zéphyr haï me vêt de glaçons würmiens, je dîne d’exquis rôtis de bœuf au kir, à l’aÿ d’âge mûr, &cætera." 76 | 77 | val headers = client.use(_.fetch(request)(resp => IO.pure(resp.headers))).unsafeRunSync() 78 | headers.get(IString("X-SAMPLE-HEADER")).value.value shouldBe "Sample value" 79 | headers.get(IString("Content-TYPE")).value.value shouldBe "text/plain; charset=UTF-8" 80 | 81 | val status = client.use(_.status(request)).unsafeRunSync() 82 | status shouldBe Status.Created 83 | } 84 | 85 | "play an ISO-8859-1 mock" in { 86 | val id = "6c23b606-29a7-4e0f-9343-87ec0a8ac5e5" // from V003 migration 87 | val request = Request[IO](uri = Uri.unsafeFromString(s"$URL/v3/$id")) 88 | 89 | val response = client.use(_.expect[String](request)).unsafeRunSync() 90 | response shouldBe 91 | "Dès Noël, où un zéphyr haï me vêt de glaçons würmiens, je dîne d'exquis rôtis de boeuf au kir, à l'aÿ d'âge mûr, &cætera." 92 | 93 | val headers = client.use(_.fetch(request)(resp => IO.pure(resp.headers))).unsafeRunSync() 94 | headers.get(IString("X-SAMPLE-HEADER")).value.value shouldBe "Sample value" 95 | headers.get(IString("Content-TYPE")).value.value shouldBe "text/plain; charset=ISO-8859-1" 96 | 97 | val status = client.use(_.status(request)).unsafeRunSync() 98 | status shouldBe Status.Ok 99 | } 100 | 101 | "stats must be updated after each play" in { 102 | val id = "c7b8ba84-19da-4f51-bbca-25ef1e4bb3da" // from V003 migration 103 | val call = Request[IO](uri = Uri.unsafeFromString(s"$URL/v3/$id")) 104 | 105 | // Call the mock multiple times 106 | val nbCalls = 35 107 | 0.until(nbCalls).foreach(_ => client.use(_.expect[String](call)).unsafeRunSync()) 108 | 109 | val requestStats = Request[IO](uri = Uri.unsafeFromString(s"$URL/api/mock/$id/stats")) 110 | val stats = client.use(_.expect[Json](requestStats)).unsafeRunSync() 111 | 112 | val totalAccess = root.totalAccess.int.getOption(stats).value 113 | val lastAccessAt = root.lastAccessAt.as[ZonedDateTime].getOption(stats).value 114 | 115 | val initialCounter = 1000 // from SQL migration 116 | totalAccess shouldBe initialCounter + nbCalls 117 | 118 | lastAccessAt.isAfter(ZonedDateTime.now().minusSeconds(20)) shouldBe true 119 | lastAccessAt.isBefore(ZonedDateTime.now()) shouldBe true 120 | } 121 | 122 | "refuse access to Admin route without token" in { 123 | val request = Request[IO](uri = Uri.unsafeFromString(s"$URL/admin/api/stats")) 124 | 125 | val status = client.use(_.status(request)).unsafeRunSync() 126 | status shouldBe Status.Unauthorized 127 | } 128 | 129 | "refuse access to Admin route with wrong token" in { 130 | val request = Request[IO](uri = Uri.unsafeFromString(s"$URL/admin/api/stats")) 131 | .withHeaders(Header("X-Auth-Token", "wrongsecret")) 132 | 133 | val status = client.use(_.status(request)).unsafeRunSync() 134 | status shouldBe Status.Forbidden 135 | } 136 | 137 | "authorize access to Admin route with the right token" in { 138 | val request = Request[IO](uri = Uri.unsafeFromString(s"$URL/admin/api/stats")) 139 | .withHeaders(Header("X-Auth-Token", "secret")) 140 | 141 | val status = client.use(_.status(request)).unsafeRunSync() 142 | status shouldBe Status.Ok 143 | } 144 | 145 | } 146 | 147 | } 148 | -------------------------------------------------------------------------------- /server/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | server { 2 | host = "0.0.0.0" 3 | port = 8080, port = ${?MOCKY_SERVER_PORT} 4 | } 5 | 6 | database { 7 | driver = "org.postgresql.Driver" 8 | url = "jdbc:postgresql://localhost/mocky", url = ${?MOCKY_DATABASE_URL} 9 | user = mocky, user = ${?MOCKY_DATABASE_USER} 10 | password = mocky, password = ${?MOCKY_DATABASE_PASSWORD} 11 | thread-pool-size = 32, thread-pool-size = ${?MOCKY_DATABASE_POOL_SIZE} 12 | } 13 | 14 | settings { 15 | # Environment (prod/dev) 16 | environment = "dev", environment = ${?MOCKY_ENVIRONMENT} 17 | # URL placed between all mocks 18 | endpoint = "http://"${server.host}":"${server.port}, endpoint = ${?MOCKY_ENDPOINT} 19 | 20 | admin { 21 | # Administration password (required!) 22 | password = ${MOCKY_ADMIN_PASSWORD} 23 | # Token in which the administration password must be sent 24 | header = "X-Auth-Token", header = ${?MOCKY_ADMIN_HEADER} 25 | } 26 | cors { 27 | # Domain allowed to send CORS requests 28 | domain: "http://localhost:3000", domain = ${?MOCKY_CORS_DOMAIN} 29 | # Domains allowed only when `settings.environment` = dev 30 | dev-domains:[] 31 | } 32 | throttle { 33 | amount: 100 # How many request are allowed by `per` duration 34 | per: 1s # How often the throttling limit is renewed 35 | max-clients: 100000 # Max number of clients allowed in parralel (more need more ram) 36 | } 37 | mock { 38 | content-max-length = 1000000 # Maximum mock content length 39 | secret-max-length = 64 # Maximum size for the mock secret 40 | } 41 | sleep { 42 | max-delay = 60s # Maximum duration allowed for the sleep feature 43 | parameter = "mocky-delay" # Parameter used to activate the sleep for the request 44 | } 45 | security { 46 | bcrypt-iterations = 14, bcrypt-iterations = ${?MOCKY_BCRYPT_ITERATIONS} 47 | # How many bcrypt iterations to run for hashing password. 48 | # Higher number means higher security but lag when creating mock 49 | # See https://www.postgresql.org/docs/11/pgcrypto.html#PGCRYPTO-ICFC-TABLE 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /server/src/main/resources/db/migration/V001__init_table_mocks_v2_v3.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | CREATE EXTENSION IF NOT EXISTS "pgcrypto"; 3 | 4 | -- 5 | -- Legacy mocks imported from mongodb database 6 | -- 7 | CREATE TABLE mocks_v2 8 | ( 9 | id character varying NOT NULL, 10 | 11 | content bytea, 12 | 13 | status integer NOT NULL, 14 | charset character varying NOT NULL, 15 | content_type character varying NOT NULL, 16 | headers jsonb, 17 | 18 | last_access_at timestamp with time zone, 19 | total_access bigint NOT NULL, 20 | 21 | CONSTRAINT mocks_v2_pkey PRIMARY KEY (id) 22 | ); 23 | 24 | -- 25 | -- New storage 26 | -- 27 | CREATE TABLE mocks_v3 28 | ( 29 | id UUID NOT NULL DEFAULT uuid_generate_v4 (), 30 | 31 | name character varying, 32 | content bytea, 33 | hash_content character varying, 34 | 35 | status integer NOT NULL, 36 | charset character varying NOT NULL, 37 | content_type character varying NOT NULL, 38 | headers jsonb, 39 | 40 | secret_token character varying NOT NULL, 41 | hash_ip character varying NOT NULL, 42 | 43 | created_at timestamp with time zone, 44 | last_access_at timestamp with time zone, 45 | expire_at timestamp with time zone, 46 | 47 | total_access bigint NOT NULL, 48 | 49 | CONSTRAINT mocks_v3_pkey PRIMARY KEY (id) 50 | ); -------------------------------------------------------------------------------- /server/src/main/resources/db/migration/V002__fake_legacy_data.sql: -------------------------------------------------------------------------------- 1 | -- Homepage Hello world 2 | INSERT INTO mocks_v2 (id, content, status, content_type, charset, headers, last_access_at, total_access) 3 | VALUES ( 4 | '5185415ba171ea3a00704eed', 5 | convert_to('{"hello":"world"}', 'UTF8'), 6 | 200, 7 | 'application/json; charset=utf-8', 8 | 'UTF-8', 9 | null, 10 | '2020-01-01', 11 | 1000 12 | ); 13 | 14 | INSERT INTO mocks_v2 (id, content, status, content_type, charset, headers, last_access_at, total_access) 15 | VALUES ( 16 | 'ascii', 17 | convert('Dès Noël, où un zéphyr haï me vêt de glaçons würmiens, je dîne d''exquis rôtis de boeuf au kir, à l''aÿ d''âge mûr, &cætera.', 'UTF8', 'LATIN1'), 18 | 200, 19 | 'text/plain', 20 | 'ISO-8859-1', 21 | '{"X-SAMPLE-HEADER": "Sample value"}'::jsonb, 22 | '2020-01-02', 23 | 5 24 | ); 25 | 26 | INSERT INTO mocks_v2 (id, content, status, content_type, charset, headers, last_access_at, total_access) 27 | VALUES ( 28 | 'utf8', 29 | 'Dès Noël, où un zéphyr haï me vêt de glaçons würmiens, je dîne d’exquis rôtis de bœuf au kir, à l’aÿ d’âge mûr, &cætera.', 30 | 200, 31 | 'text/plain; charset=utf-8', 32 | 'UTF-8', 33 | '{"X-SAMPLE-HEADER": "Sample value"}'::jsonb, 34 | '2020-01-05', 35 | 100 36 | ); -------------------------------------------------------------------------------- /server/src/main/resources/db/migration/V003__fake_v3_data.sql: -------------------------------------------------------------------------------- 1 | -- Homepage Hello world 2 | INSERT INTO mocks_v3 (id, content, hash_content, status, charset, content_type, headers, secret_token, hash_ip, created_at, last_access_at, total_access) 3 | VALUES ( 4 | 'c7b8ba84-19da-4f51-bbca-25ef1e4bb3da', 5 | convert_to('{"hello":"world"}', 'UTF8'), 6 | encode(digest('{"hello":"world"}', 'sha256'), 'hex'), 7 | 200, 8 | 'UTF-8', 9 | 'application/json; charset=utf-8', 10 | null, 11 | crypt('secret', gen_salt('bf', 10)), 12 | encode(digest('127.0.0.1', 'sha1'), 'hex'), 13 | '2020-01-01', 14 | '2020-05-01', 15 | 1000 16 | ); 17 | 18 | INSERT INTO mocks_v3 (id, content, hash_content, status, charset, content_type, headers, secret_token, hash_ip, created_at, last_access_at, total_access) 19 | VALUES ( 20 | '6c23b606-29a7-4e0f-9343-87ec0a8ac5e5', 21 | convert('Dès Noël, où un zéphyr haï me vêt de glaçons würmiens, je dîne d''exquis rôtis de boeuf au kir, à l''aÿ d''âge mûr, &cætera.', 'UTF8', 'LATIN1'), 22 | 'fakehashascii', 23 | 200, 24 | 'ISO-8859-1', 25 | 'text/plain', 26 | '{"X-SAMPLE-HEADER": "Sample value"}'::jsonb, 27 | crypt('secret', gen_salt('bf', 10)), 28 | encode(digest('127.0.0.1', 'sha1'), 'hex'), 29 | '2020-01-01', 30 | '2020-01-02', 31 | 5 32 | ); 33 | 34 | INSERT INTO mocks_v3 (id, content, hash_content, status, charset, content_type, headers, secret_token, hash_ip, created_at, last_access_at, total_access) 35 | VALUES ( 36 | '48e9c41b-de8c-4aeb-99a8-2f3abe3e5efa', 37 | 'Dès Noël, où un zéphyr haï me vêt de glaçons würmiens, je dîne d’exquis rôtis de bœuf au kir, à l’aÿ d’âge mûr, &cætera.', 38 | 'fakehashutf8', 39 | 201, 40 | 'UTF-8', 41 | 'text/plain; charset=utf-8', 42 | '{"X-SAMPLE-HEADER": "Sample value"}'::jsonb, 43 | crypt('secret', gen_salt('bf', 10)), 44 | encode(digest('127.0.0.1', 'sha1'), 'hex'), 45 | '2020-01-01', 46 | '2020-01-05', 47 | 100 48 | ); -------------------------------------------------------------------------------- /server/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | [%thread] %highlight(%-5level) %cyan(%logger) - %msg %n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/HttpServer.scala: -------------------------------------------------------------------------------- 1 | package io.mocky 2 | 3 | import scala.concurrent.ExecutionContext.Implicits.global 4 | import scala.concurrent.duration._ 5 | import cats.effect._ 6 | import doobie.hikari.HikariTransactor 7 | import doobie.util.ExecutionContexts 8 | import org.http4s.server.blaze.BlazeServerBuilder 9 | import org.log4s._ 10 | import io.mocky.config.Config 11 | import io.mocky.db.Database 12 | 13 | object HttpServer { 14 | private val logger = getLogger 15 | 16 | def create(configFile: String)(implicit 17 | contextShift: ContextShift[IO], 18 | concurrentEffect: ConcurrentEffect[IO], 19 | timer: Timer[IO]): IO[ExitCode] = { 20 | 21 | Config.load(configFile).flatMap { config => 22 | logger.info(s"Start app with $configFile: $config") 23 | resources(config) 24 | }.use(create) 25 | } 26 | 27 | def createFromConfig(config: Config)(implicit 28 | contextShift: ContextShift[IO], 29 | concurrentEffect: ConcurrentEffect[IO], 30 | timer: Timer[IO]): IO[ExitCode] = { 31 | 32 | resources(config).use(create) 33 | } 34 | 35 | private def resources(config: Config)(implicit contextShift: ContextShift[IO]): Resource[IO, Resources] = { 36 | for { 37 | ec <- ExecutionContexts.fixedThreadPool[IO](config.database.threadPoolSize) 38 | blocker <- Blocker[IO] 39 | transactor <- Database.transactor(config.database, ec, blocker) 40 | } yield Resources(transactor, config) 41 | } 42 | 43 | private def create(resources: Resources)(implicit 44 | concurrentEffect: ConcurrentEffect[IO], 45 | timer: Timer[IO], 46 | contextShift: ContextShift[IO]): IO[ExitCode] = { 47 | 48 | for { 49 | _ <- Database.initialize(resources.transactor) 50 | routing = new Routing().wire(resources) 51 | exitCode <- 52 | BlazeServerBuilder[IO](global) 53 | .bindHttp(resources.config.server.port, resources.config.server.host) 54 | .withHttpApp(routing) 55 | .withIdleTimeout(resources.config.settings.sleep.maxDelay.plus(5.seconds)) 56 | .withResponseHeaderTimeout(resources.config.settings.sleep.maxDelay.plus(4.seconds)) 57 | .serve 58 | .compile 59 | .drain 60 | .as(ExitCode.Success) 61 | } yield exitCode 62 | } 63 | 64 | final case class Resources(transactor: HikariTransactor[IO], config: Config) 65 | } 66 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/Routing.scala: -------------------------------------------------------------------------------- 1 | package io.mocky 2 | 3 | import cats.effect.{ ContextShift, IO, Timer } 4 | import cats.implicits._ 5 | import org.http4s.Http 6 | import org.http4s.implicits._ 7 | import org.http4s.server.Router 8 | 9 | import io.mocky.HttpServer.Resources 10 | import io.mocky.config.ThrottleSettings 11 | import io.mocky.http.middleware.IPThrottler 12 | import io.mocky.repositories.{ MockV2Repository, MockV3Repository } 13 | import io.mocky.services._ 14 | 15 | class Routing { 16 | 17 | def wire(resources: Resources)(implicit timer: Timer[IO], contextShift: ContextShift[IO]): Http[IO, IO] = { 18 | val repositoryV2 = new MockV2Repository(resources.transactor) 19 | val repositoryV3 = new MockV3Repository(resources.transactor, resources.config.settings.security) 20 | 21 | val mockApiService = new MockApiService(repositoryV3, resources.config.settings) 22 | val mockAdminApiService = new MockAdminApiService(repositoryV2, repositoryV3, resources.config.settings) 23 | val mockRunnerService = new MockRunnerService(repositoryV2, repositoryV3, resources.config.settings) 24 | val designerService = new DesignerService(resources.config.settings.cors) 25 | val statusService = new StatusService() 26 | 27 | val throttleConfig = resources.config.settings.throttle 28 | 29 | routes(mockApiService, mockAdminApiService, mockRunnerService, statusService, designerService, throttleConfig) 30 | } 31 | 32 | def routes( 33 | mockApiService: MockApiService, 34 | mockAdminApiService: MockAdminApiService, 35 | mockRunnerService: MockRunnerService, 36 | statusService: StatusService, 37 | designerService: DesignerService, 38 | throttleConfig: ThrottleSettings)(implicit timer: Timer[IO], contextShift: ContextShift[IO]): Http[IO, IO] = { 39 | 40 | val mockRoutes = mockRunnerService.routing 41 | val apiRoutes = statusService.routes <+> mockApiService.routing 42 | val designerRoutes = designerService.routes 43 | val adminRoutes = mockAdminApiService.routing 44 | 45 | val allRoutes = Router( 46 | "/admin" -> adminRoutes, 47 | "/" -> (designerRoutes <+> mockRoutes <+> apiRoutes) 48 | ).orNotFound 49 | 50 | val throttledRoutes = IPThrottler(throttleConfig)(allRoutes) 51 | 52 | throttledRoutes 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/ServerApp.scala: -------------------------------------------------------------------------------- 1 | package io.mocky 2 | 3 | import cats.effect.{ ExitCode, IO, IOApp } 4 | 5 | object ServerApp extends IOApp { 6 | def run(args: List[String]): IO[ExitCode] = { 7 | val configFile = System.getenv().getOrDefault("MOCKY_CONFIGURATION_FILE", "application.conf") 8 | HttpServer.create(configFile) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/config/Config.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.config 2 | 3 | import scala.concurrent.duration._ 4 | 5 | import cats.effect.{ Blocker, ContextShift, IO, Resource } 6 | import com.typesafe.config.ConfigFactory 7 | import pureconfig._ 8 | import pureconfig.generic.auto._ 9 | import pureconfig.module.catseffect.syntax._ 10 | 11 | sealed case class ServerConfig(host: String, port: Int) 12 | 13 | sealed case class DatabaseConfig(driver: String, url: String, user: String, password: String, threadPoolSize: Int = 8) { 14 | assert(threadPoolSize >= 4) 15 | } 16 | 17 | sealed case class ThrottleSettings(amount: Int, per: FiniteDuration, maxClients: Long) { 18 | assert(amount > 20) 19 | assert(maxClients > 100) 20 | } 21 | 22 | case class SleepSettings(maxDelay: FiniteDuration, parameter: String) { 23 | assert(maxDelay < 5.minutes) 24 | } 25 | 26 | sealed case class MockSettings( 27 | contentMaxLength: Int, 28 | secretMaxLength: Int 29 | ) { 30 | assert(contentMaxLength > 1000 && contentMaxLength < 10000000) 31 | } 32 | 33 | sealed case class SecuritySettings(bcryptIterations: Int) { 34 | assert(bcryptIterations >= 4 && bcryptIterations <= 31) 35 | } 36 | 37 | sealed case class AdminSettings(header: String, password: String) { 38 | assert(header.nonEmpty) 39 | assert(password.nonEmpty) 40 | } 41 | 42 | sealed case class CorsSettings(domain: String, devDomains: Option[Seq[String]] = None) { 43 | assert(domain.nonEmpty) 44 | } 45 | 46 | sealed case class Settings( 47 | environment: String, 48 | endpoint: String, 49 | cors: CorsSettings, 50 | mock: MockSettings, 51 | security: SecuritySettings, 52 | throttle: ThrottleSettings, 53 | sleep: SleepSettings, 54 | admin: AdminSettings) 55 | 56 | sealed case class Config(server: ServerConfig, database: DatabaseConfig, settings: Settings) 57 | 58 | object Config { 59 | def load(configFile: String)(implicit cs: ContextShift[IO]): Resource[IO, Config] = { 60 | Blocker[IO].flatMap { blocker => 61 | Resource.liftF(ConfigSource.fromConfig(ConfigFactory.load(configFile)).loadF[IO, Config](blocker)) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/db/Database.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.db 2 | 3 | import scala.concurrent.ExecutionContext 4 | 5 | import cats.effect.{ Blocker, ContextShift, IO, Resource } 6 | import doobie.hikari.HikariTransactor 7 | import org.flywaydb.core.Flyway 8 | 9 | import io.mocky.config.DatabaseConfig 10 | 11 | object Database { 12 | def transactor(config: DatabaseConfig, executionContext: ExecutionContext, blocker: Blocker)(implicit 13 | contextShift: ContextShift[IO]): Resource[IO, HikariTransactor[IO]] = { 14 | HikariTransactor.newHikariTransactor[IO]( 15 | config.driver, 16 | config.url, 17 | config.user, 18 | config.password, 19 | executionContext, 20 | blocker 21 | ) 22 | } 23 | 24 | def initialize(transactor: HikariTransactor[IO]): IO[Unit] = { 25 | transactor.configure { dataSource => 26 | IO { 27 | val flyWay = Flyway.configure().dataSource(dataSource).load() 28 | flyWay.migrate() 29 | () 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/db/DoobieLogHandler.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.db 2 | 3 | import com.typesafe.scalalogging.StrictLogging 4 | import doobie.util.log.{ ExecFailure, LogHandler, ProcessingFailure, Success } 5 | 6 | trait DoobieLogHandler { 7 | self: StrictLogging => 8 | 9 | implicit protected val doobieLogHandler: LogHandler = { 10 | LogHandler { 11 | 12 | case Success(s, a, e1, e2) => 13 | // If trace level is not enabled, just log a succinct message 14 | if (!self.logger.underlying.isTraceEnabled) { 15 | self.logger.debug(s"SQL request executed with success in ${(e1 + e2).toMillis.toString} ms") 16 | } else { 17 | self.logger.trace(s"""Successful Statement Execution: 18 | | 19 | | ${s.linesIterator.dropWhile(_.trim.isEmpty).mkString("\n ")} 20 | | 21 | | arguments = [${a.mkString(", ")}] 22 | | elapsed = ${e1.toMillis.toString} ms exec + ${e2.toMillis.toString} ms processing (${(e1 + e2).toMillis.toString} ms total) 23 | """.stripMargin) 24 | } 25 | case ProcessingFailure(s, a, e1, e2, t) => 26 | self.logger.error(s"""Failed Resultset Processing: 27 | | 28 | | ${s.linesIterator.dropWhile(_.trim.isEmpty).mkString("\n ")} 29 | | 30 | | arguments = [${a.mkString(", ")}] 31 | | elapsed = ${e1.toMillis.toString} ms exec + ${e2.toMillis.toString} ms processing (failed) (${(e1 + e2).toMillis.toString} ms total) 32 | | failure = ${t.getMessage} 33 | """.stripMargin) 34 | 35 | case ExecFailure(s, a, e1, t) => 36 | self.logger.error(s"""Failed Statement Execution: 37 | | 38 | | ${s.linesIterator.dropWhile(_.trim.isEmpty).mkString("\n ")} 39 | | 40 | | arguments = [${a.mkString(", ")}] 41 | | elapsed = ${e1.toMillis.toString} ms exec (failed) 42 | | failure = ${t.getMessage} 43 | """.stripMargin) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/http/HttpMockResponse.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.http 2 | 3 | import cats.Applicative 4 | import cats.effect.IO 5 | import org.http4s._ 6 | import org.http4s.dsl.Http4sDsl 7 | import org.http4s.headers.`Content-Length` 8 | 9 | import io.mocky.models.mocks.MockResponse 10 | 11 | trait HttpMockResponse extends Http4sDsl[IO] { 12 | 13 | protected def respondWithMock[F[_]](mock: MockResponse)(implicit F: Applicative[IO]): IO[Response[IO]] = { 14 | 15 | val entity = prepareEntity(mock) 16 | val headers = prepareHeaders(mock, entity) 17 | 18 | F.pure( 19 | Response[IO]( 20 | status = mock.status, 21 | headers = headers, 22 | body = entity.body 23 | ) 24 | ) 25 | } 26 | 27 | private def entityLengthHeader(entity: Entity[IO]): Headers = { 28 | entity.length 29 | .flatMap(length => `Content-Length`.fromLong(length).toOption) 30 | .map(header => Headers.of(header)) 31 | .getOrElse(Headers.empty) 32 | } 33 | 34 | private def prepareEntity(mock: MockResponse): Entity[IO] = { 35 | mock.content match { 36 | case Some(content) => EntityEncoder.byteArrayEncoder[IO].toEntity(content) 37 | case None => Entity.empty 38 | } 39 | 40 | } 41 | 42 | private def prepareHeaders(mock: MockResponse, entity: Entity[IO]): Headers = { 43 | val userHeaders = mock.headers 44 | val lengthHeaders = entityLengthHeader(entity) 45 | val contentTypeHeader = Headers.of(mock.contentType) 46 | 47 | userHeaders ++ lengthHeaders ++ contentTypeHeader 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/http/JsonMarshalling.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.http 2 | 3 | import cats.Applicative 4 | import cats.data.NonEmptyList 5 | import cats.effect.Sync 6 | import cats.syntax.all._ 7 | import io.circe._ 8 | import org.http4s._ 9 | import org.http4s.circe.CirceInstances 10 | 11 | /** 12 | * Trait allowing to decode/encode JSON request/response, with custom decoding errors 13 | */ 14 | trait JsonMarshalling { 15 | 16 | private val instances = CirceInstances.builder 17 | .withJsonDecodeError(UnprocessableEntityFailure) 18 | .build 19 | 20 | /** 21 | * Decode a JSON payload as T, and run a response requiring T 22 | */ 23 | def decodeJson[F[_], T](request: Request[F])( 24 | block: T => F[Response[F]])(implicit F: Applicative[F], sync: Sync[F], decoder: Decoder[T]): F[Response[F]] = { 25 | 26 | instances.accumulatingJsonOf[F, T].decode(request, strict = false).value.flatMap { 27 | case Right(obj) => block(obj) 28 | case Left(error) => F.pure(error.toHttpResponse[F](request.httpVersion)) 29 | } 30 | 31 | } 32 | 33 | /** 34 | * Allow to return any A as JSON response when an Encoder[A] is in the implicit scope 35 | */ 36 | implicit def jsonEncoder[F[_], A](implicit encoder: Encoder[A]): EntityEncoder[F, A] = instances.jsonEncoderOf 37 | } 38 | 39 | final case class UnprocessableEntityFailure(json: Json, failures: NonEmptyList[DecodingFailure]) 40 | extends DecodeFailure 41 | with CirceInstances { 42 | 43 | /** 44 | * Format JSON errors 45 | * {"errors": [ ".path": "error-code ] } 46 | */ 47 | override def toHttpResponse[F[_]](httpVersion: HttpVersion): Response[F] = { 48 | val errors = Json.fromFields(failures.toList.map { failure => 49 | val path = CursorOp.opsToPath(failure.history) 50 | val error = failure.message 51 | path -> Json.fromString(error) 52 | }) 53 | Response(Status.UnprocessableEntity, httpVersion).withEntity(Json.obj("errors" -> errors)) 54 | } 55 | 56 | override val message = s"Invalid JSON received" 57 | override def cause: Option[Throwable] = None 58 | } 59 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/http/middleware/Authorization.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.http.middleware 2 | 3 | import cats.data.{ Kleisli, OptionT } 4 | import cats.effect.IO 5 | import io.chrisdavenport.log4cats.SelfAwareStructuredLogger 6 | import io.chrisdavenport.log4cats.slf4j.Slf4jLogger 7 | import org.http4s.implicits._ 8 | import org.http4s.server.AuthMiddleware 9 | import org.http4s.{ AuthedRoutes, Request, Response, Status } 10 | 11 | import io.mocky.config.AdminSettings 12 | 13 | sealed trait Role 14 | case object Admin extends Role 15 | 16 | sealed trait AuthenticationError { 17 | val message: String 18 | val status: Status 19 | } 20 | final case class Unauthorized(message: String) extends AuthenticationError { 21 | val status = Status.Unauthorized 22 | } 23 | final case class Forbidden(message: String) extends AuthenticationError { 24 | val status = Status.Forbidden 25 | } 26 | 27 | class Authorization(settings: AdminSettings) { 28 | 29 | private val logger: SelfAwareStructuredLogger[IO] = Slf4jLogger.getLogger[IO] 30 | 31 | /** 32 | * Basic Admin authorization, working by sending a token in `settings.admin.header` header that 33 | * will be matched with `settings.admin.token` 34 | */ 35 | val Administrator = AuthMiddleware(authAdministrator, onFailure) 36 | 37 | private def onFailure: AuthedRoutes[AuthenticationError, IO] = { 38 | Kleisli { req => 39 | OptionT.liftF { 40 | for { 41 | _ <- logger.warn(s"Unauthorized access to ADMIN route: ${req.context.message} from ${req.req}") 42 | response <- IO.pure(Response[IO](req.context.status).withEntity(req.context.message)) 43 | } yield response 44 | } 45 | } 46 | } 47 | 48 | private def authAdministrator: Kleisli[IO, Request[IO], Either[AuthenticationError, Role]] = Kleisli { 49 | request => 50 | IO.pure(for { 51 | header <- request.headers.get(settings.header.ci).toRight( 52 | Unauthorized("Couldn't find the authorization header")) 53 | res <- Either.cond(header.value == settings.password, Admin, Forbidden(s"Invalid token '${header.value}'")) 54 | } yield res) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/http/middleware/IPThrottler.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.http.middleware 2 | 3 | import java.util.concurrent.TimeUnit.NANOSECONDS 4 | import scala.concurrent.duration._ 5 | 6 | import cats._ 7 | import cats.data.Kleisli 8 | import cats.effect.concurrent.Ref 9 | import cats.effect.{ Clock, ContextShift, IO, Sync } 10 | import cats.implicits._ 11 | import com.github.blemale.scaffeine.{ AsyncLoadingCache, Scaffeine } 12 | import com.typesafe.scalalogging.StrictLogging 13 | import org.http4s._ 14 | 15 | import io.mocky.config.ThrottleSettings 16 | import io.mocky.utils.HttpUtil 17 | 18 | /** 19 | * Middleware to support wrapping json responses in jsonp. 20 | * Implementation based on `org.http4s.server.middleware.Jsonp`, with relaxed constraint: 21 | * the original content-type hasn't to be application/json`, it'll be overridden if required. 22 | * 23 | * If the wrapping is done, the response Content-Type is changed into `application/javascript` 24 | * and the appropriate jsonp callback is applied. 25 | */ 26 | object IPThrottler extends StrictLogging { 27 | 28 | sealed abstract class TokenAvailability extends Product with Serializable 29 | case object TokenAvailable extends TokenAvailability 30 | final case class TokenUnavailable(retryAfter: Option[FiniteDuration]) extends TokenAvailability 31 | 32 | /** 33 | * A token bucket for use with the `org.http4s.server.middleware.Throttle` middleware. 34 | * Consumers can take tokens which will be refilled over time. 35 | * Implementations are required to provide their own refill mechanism. 36 | * 37 | * Possible implementations include a remote TokenBucket service to coordinate between different application instances. 38 | */ 39 | private trait TokenBucket { 40 | def takeToken(ip: String): IO[TokenAvailability] 41 | } 42 | 43 | private object TokenBucket { 44 | 45 | /** 46 | * Creates an in-memory `TokenBucket`. 47 | * 48 | * @param capacity the number of tokens the bucket can hold and starts with. 49 | * @param refillEvery the frequency with which to add another token if there is capacity spare. 50 | * @return A task to create the `TokenBucket`. 51 | */ 52 | def local(capacity: Int, refillEvery: FiniteDuration, maxClients: Long)(implicit 53 | F: Sync[IO], 54 | clock: Clock[IO], 55 | cs: ContextShift[IO]): TokenBucket = { 56 | 57 | def getTime: IO[Long] = clock.monotonic(NANOSECONDS) 58 | 59 | new TokenBucket { 60 | 61 | private val buckets: AsyncLoadingCache[String, Ref[IO, (Double, Long)]] = Scaffeine() 62 | .maximumSize(maxClients) 63 | .buildAsyncFuture[String, Ref[IO, (Double, Long)]]( 64 | loader = _ => getTime.flatMap(time => Ref[IO].of((capacity.toDouble, time))).unsafeToFuture() 65 | ) 66 | 67 | override def takeToken(ip: String): IO[TokenAvailability] = { 68 | val bucket = IO.fromFuture(IO(buckets.get(ip))) 69 | 70 | bucket.flatMap { counter => 71 | val attemptUpdate = counter.access.flatMap { 72 | case ((previousTokens, previousTime), setter) => 73 | getTime.flatMap { currentTime => 74 | val timeDifference = currentTime - previousTime 75 | val tokensToAdd = timeDifference.toDouble / refillEvery.toNanos.toDouble 76 | val newTokenTotal = Math.min(previousTokens + tokensToAdd, capacity.toDouble) 77 | 78 | val attemptSet: IO[Option[TokenAvailability]] = if (newTokenTotal >= 1) { 79 | setter((newTokenTotal - 1, currentTime)).map(_.guard[Option].as(TokenAvailable)) 80 | } else { 81 | val timeToNextToken = refillEvery.toNanos - timeDifference 82 | val successResponse = TokenUnavailable(timeToNextToken.nanos.some) 83 | setter((newTokenTotal, currentTime)).map(_.guard[Option].as(successResponse)) 84 | } 85 | 86 | attemptSet 87 | } 88 | } 89 | 90 | def loop: IO[TokenAvailability] = attemptUpdate.flatMap { attempt => 91 | attempt.fold(loop)(token => token.pure[IO]) 92 | } 93 | 94 | loop 95 | 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * Limits the supplied service to a given rate of calls using an in-memory `TokenBucket` 104 | * 105 | * @param amount the number of calls to the service to permit within the given time period. 106 | * @param per the time period over which a given number of calls is permitted. 107 | * @param maxClients number of buckets to keep in memory 108 | * @param http the service to transform. 109 | * @return a task containing the transformed service. 110 | */ 111 | def apply(amount: Int, per: FiniteDuration, maxClients: Long)( 112 | http: Http[IO, IO])(implicit F: Sync[IO], timer: Clock[IO], cs: ContextShift[IO]): Http[IO, IO] = { 113 | 114 | val refillFrequency = per / amount.toLong 115 | val bucket = TokenBucket.local(amount, refillFrequency, maxClients) 116 | 117 | apply(bucket)(http) 118 | } 119 | 120 | def apply(config: ThrottleSettings)( 121 | http: Http[IO, IO])(implicit F: Sync[IO], timer: Clock[IO], cs: ContextShift[IO]): Http[IO, IO] = { 122 | apply(config.amount, config.per, config.maxClients)(http) 123 | } 124 | 125 | private def throttleResponse(retryAfter: Option[FiniteDuration], ip: String): Response[IO] = { 126 | logger.warn(s"Access from ip $ip throttled") 127 | Response[IO](Status.TooManyRequests) 128 | .withHeaders(Headers.of(Header("X-Retry-After", retryAfter.map(_.toString()).getOrElse("unknown")))) 129 | } 130 | 131 | /** 132 | * Limits the supplied service using a provided `TokenBucket` 133 | * 134 | * @param bucket a `TokenBucket` to use to track the rate of incoming requests. 135 | * @param http the service to transform. 136 | * @return a task containing the transformed service. 137 | */ 138 | private def apply(bucket: TokenBucket)(http: Http[IO, IO])(implicit F: Monad[IO]): Http[IO, IO] = { 139 | Kleisli { req => 140 | val ip = HttpUtil.getIP(req) 141 | bucket.takeToken(ip).flatMap { 142 | case TokenAvailable => http(req) 143 | case TokenUnavailable(retryAfter) => throttleResponse(retryAfter, ip).pure[IO] 144 | } 145 | } 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/http/middleware/Jsonp.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.http.middleware 2 | 3 | import java.nio.charset.StandardCharsets 4 | 5 | import cats.data.Kleisli 6 | import cats.implicits._ 7 | import cats.{ Applicative, Functor } 8 | import fs2.Chunk 9 | import fs2.Stream._ 10 | import org.http4s._ 11 | import org.http4s.headers._ 12 | 13 | /** 14 | * Middleware to support wrapping json responses in jsonp. 15 | * Implementation based on `org.http4s.server.middleware.Jsonp`, with relaxed constraint: 16 | * the original content-type hasn't to be `application/json`, it'll be overridden if required. 17 | * 18 | * If the wrapping is done, the response Content-Type is changed into `application/javascript` 19 | * and the appropriate jsonp callback is applied. 20 | */ 21 | object Jsonp { 22 | 23 | val DEFAULT_PARAMETER = "callback" 24 | 25 | // A regex to match a valid javascript function name to shield the client from some jsonp related attacks 26 | private val ValidCallback = 27 | """^((?!(?:do|if|in|for|let|new|try|var|case|else|enum|eval|false|null|this|true|void|with|break|catch|class|const|super|throw|while|yield|delete|export|import|public|return|static|switch|typeof|default|extends|finally|package|private|continue|debugger|function|arguments|interface|protected|implements|instanceof)$)[$A-Z_a-z-﹏0-9_]*)$""".r 28 | 29 | def apply[F[_]: Applicative, G[_]: Functor](http: Http[F, G]): Http[F, G] = apply(DEFAULT_PARAMETER)(http) 30 | 31 | def apply[F[_]: Applicative, G[_]: Functor](callbackParam: String)(http: Http[F, G]): Http[F, G] = 32 | Kleisli { req => 33 | req.params.get(callbackParam) match { 34 | case Some(ValidCallback(callback)) => 35 | http.map(jsonp(_, callback)).apply(req) 36 | case Some(invalidCallback @ _) => 37 | Response[G](Status.BadRequest).withEntity(s"Not a valid callback name.").pure[F] 38 | case None => http(req) 39 | } 40 | } 41 | 42 | private def jsonp[F[_]: Functor](resp: Response[F], callback: String) = { 43 | val begin = beginJsonp(callback) 44 | val end = EndJsonp 45 | val jsonpBody = chunk(begin) ++ resp.body ++ chunk(end) 46 | 47 | val newHeaders = resp.headers.get(`Content-Length`) match { 48 | case None => resp.headers 49 | case Some(oldContentLength) => 50 | resp.headers 51 | .filterNot(_.is(`Content-Length`)) 52 | .put(`Content-Length`.unsafeFromLong(oldContentLength.length + begin.size + end.size)) 53 | } 54 | 55 | resp 56 | .copy(body = jsonpBody) 57 | .withHeaders(newHeaders) 58 | .withContentType(`Content-Type`(MediaType.application.javascript)) 59 | } 60 | 61 | private def beginJsonp(callback: String) = 62 | Chunk.bytes((callback + "(").getBytes(StandardCharsets.UTF_8)) 63 | 64 | private val EndJsonp = 65 | Chunk.bytes(");".getBytes(StandardCharsets.UTF_8)) 66 | } 67 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/http/middleware/Sleep.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.http.middleware 2 | 3 | import scala.concurrent.duration._ 4 | import scala.util.{ Failure, Success, Try } 5 | 6 | import cats.data.Kleisli 7 | import cats.effect.Timer 8 | import cats.implicits._ 9 | import cats.{ Applicative, Functor } 10 | import org.http4s._ 11 | 12 | import io.mocky.config.SleepSettings 13 | 14 | /** 15 | * When the `settings.sleep.parameter` query parameter is set with a duration inferior to `settings.sleep.max-delay`, 16 | * the server will sleep during this duration before sending the response to the client 17 | */ 18 | class Sleep(settings: SleepSettings) { 19 | 20 | sealed private trait Delay 21 | private case class ValidDelay(duration: FiniteDuration) extends Delay 22 | private case class InvalidDelay(cause: String) extends Delay 23 | 24 | private object Delay { 25 | def parse(arg: String): Option[Delay] = { 26 | Try(Duration(arg)) match { 27 | case Success(d: FiniteDuration) if d.gteq(Duration.Zero) && d.lteq(settings.maxDelay) => 28 | Some(ValidDelay(d)) 29 | case Success(_) => Some(InvalidDelay(s"Delay must be between 0 and ${settings.maxDelay}")) 30 | case Failure(_) => Some(InvalidDelay("Invalid duration (expected: 10s, 100ms, 50ns")) 31 | } 32 | } 33 | } 34 | 35 | def apply[F[_]: Applicative, G[_]: Functor](http: Http[F, G])(implicit timer: Timer[F]): Http[F, G] = { 36 | Kleisli { req => 37 | req.params.get(settings.parameter).flatMap(Delay.parse) match { 38 | case Some(ValidDelay(duration)) => 39 | timer.sleep(duration) *> http(req) 40 | case Some(InvalidDelay(cause)) => 41 | Response[G](Status.BadRequest).withEntity(cause).pure[F] 42 | case None => http(req) 43 | } 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/Gate.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models 2 | 3 | import io.mocky.http.middleware.Role 4 | 5 | case class Gate[T <: Role](role: T) extends AnyVal 6 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/admin/Stats.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.admin 2 | 3 | import io.circe.Encoder 4 | import io.circe.generic.semiauto._ 5 | 6 | case class Stats( 7 | nbMocks: Long, 8 | totalAccess: Long, 9 | nbMocksAccessedInMonth: Long, 10 | nbMocksCreatedInMonth: Long, 11 | nbMocksNotAccessedInYear: Long, 12 | nbDistinctIps: Long, 13 | mockAverageLength: Int 14 | ) 15 | 16 | object Stats { 17 | implicit val encoderStats: Encoder.AsObject[Stats] = deriveEncoder[Stats] 18 | } 19 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/errors/MockNotFoundError.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.errors 2 | 3 | case object MockNotFoundError 4 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/mocks/Mock.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.mocks 2 | 3 | import io.circe.generic.semiauto._ 4 | import io.circe.{ Encoder, Json } 5 | 6 | final case class Mock( 7 | content: Option[Array[Byte]], 8 | status: Int, 9 | contentType: String, 10 | charset: String, 11 | headers: Option[Json]) 12 | 13 | object Mock { 14 | implicit val mockEncoder: Encoder.AsObject[Mock] = deriveEncoder[Mock] 15 | } 16 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/mocks/MockCreatedResponse.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.mocks 2 | 3 | import java.time.ZonedDateTime 4 | import java.time.format.DateTimeFormatter 5 | import java.util.UUID 6 | 7 | import io.circe.Encoder 8 | import io.circe.generic.semiauto._ 9 | 10 | import io.mocky.models.mocks.actions.CreateUpdateMock 11 | import io.mocky.models.mocks.feedbacks.MockCreated 12 | 13 | case class MockCreatedResponse private (id: UUID, secret: String, expireAt: Option[ZonedDateTime], link: String) 14 | 15 | object MockCreatedResponse { 16 | def apply(created: MockCreated, data: CreateUpdateMock, endpoint: String): MockCreatedResponse = { 17 | new MockCreatedResponse(created.id, data.secret, data.expireAt, s"$endpoint/v3/${created.id}") 18 | } 19 | 20 | implicit val zonedDTEncoder: Encoder[ZonedDateTime] = 21 | Encoder.encodeZonedDateTimeWithFormatter(DateTimeFormatter.ISO_OFFSET_DATE_TIME) 22 | implicit val mockCreatedResponseEncoder: Encoder.AsObject[MockCreatedResponse] = deriveEncoder[MockCreatedResponse] 23 | } 24 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/mocks/MockResponse.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.mocks 2 | 3 | import io.circe.Json 4 | import org.http4s._ 5 | import org.http4s.headers.`Content-Type` 6 | 7 | final case class MockResponse( 8 | content: Option[Array[Byte]], 9 | charset: Charset, 10 | status: Status, 11 | contentType: `Content-Type`, 12 | headers: Headers 13 | ) 14 | 15 | object MockResponse { 16 | def apply(mock: Mock): MockResponse = { 17 | val status = parseStatus(mock.status) 18 | val charset = parseCharset(mock.charset) 19 | val headers = mock.headers.map(parseHeaders).getOrElse(Headers.empty) 20 | val contentType = `Content-Type`.parse(mock.contentType) 21 | .getOrElse(DEFAULT_CONTENT_TYPE) 22 | .withCharset(charset) 23 | 24 | MockResponse(mock.content, charset, status, contentType, headers) 25 | } 26 | 27 | val DEFAULT_CHARSET = Charset.`UTF-8` 28 | val DEFAULT_CONTENT_TYPE = `Content-Type`(MediaType.application.json) 29 | 30 | private def parseCharset(charset: String) = Charset.fromString(charset).getOrElse(DEFAULT_CHARSET) 31 | private def parseStatus(status: Int) = Status.fromInt(status).getOrElse(Status.Ok) 32 | private def parseHeaders(headers: Json) = Headers( 33 | headers.as[Map[String, String]].getOrElse(Map.empty).view.map((Header.apply _).tupled).to(List)) 34 | } 35 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/mocks/MockStats.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.mocks 2 | 3 | import java.sql.Timestamp 4 | import java.time.{ ZoneId, ZonedDateTime } 5 | 6 | import io.circe.Encoder 7 | import io.circe.generic.semiauto._ 8 | 9 | final case class MockStats(createdAt: Timestamp, lastAccessAt: Option[Timestamp], totalAccess: Long) 10 | 11 | object MockStats { 12 | implicit val timestampEncoder: Encoder[Timestamp] = 13 | Encoder.encodeZonedDateTime.contramap(ts => ZonedDateTime.from(ts.toInstant.atZone(ZoneId.of("UTC")))) 14 | implicit val mockStatsEncoder: Encoder.AsObject[MockStats] = deriveEncoder[MockStats] 15 | } 16 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/mocks/actions/CreateUpdateMock.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.mocks.actions 2 | 3 | import java.time.ZonedDateTime 4 | 5 | import io.circe.syntax._ 6 | import io.circe.{ Decoder, Json } 7 | import io.tabmo.circe.extra.rules.{ IntRules, StringRules } 8 | 9 | import io.mocky.config.MockSettings 10 | import io.mocky.models.mocks.actions.CreateUpdateMock.CreateUpdateMockHeaders 11 | import io.mocky.models.mocks.enums.Expiration 12 | import io.mocky.utils.DateUtil 13 | 14 | final case class CreateUpdateMock( 15 | name: Option[String], 16 | content: Option[String], 17 | contentType: String, 18 | status: Int, 19 | charset: String, 20 | headers: Option[CreateUpdateMockHeaders], 21 | secret: String, 22 | expiration: Expiration, 23 | ip: Option[String] = None) { 24 | def contentArrayBytes: Option[Array[Byte]] = content.map(_.getBytes("UTF-8")) 25 | def headersJson: Option[Json] = headers.map(_.underlyingMap).flatMap { map => Option.when(map.nonEmpty)(map.asJson) } 26 | def withIp(ip: String): CreateUpdateMock = copy(ip = Some(ip)) 27 | def expireAt: Option[ZonedDateTime] = expiration.duration.map(DateUtil.future) 28 | } 29 | 30 | object CreateUpdateMock { 31 | final case class CreateUpdateMockHeaders(underlyingMap: Map[String, String]) 32 | 33 | implicit private val createMockHeadersDecoder: Decoder[CreateUpdateMockHeaders] = { 34 | Decoder.instance[CreateUpdateMockHeaders] { c => 35 | c.as[Map[String, String]].map(CreateUpdateMockHeaders.apply) 36 | } 37 | } 38 | 39 | def decoder(settings: MockSettings): Decoder[CreateUpdateMock] = 40 | Decoder.instance[CreateUpdateMock] { 41 | c => 42 | import io.tabmo.json.rules._ 43 | for { 44 | name <- c.downField("name").readOpt(StringRules.notBlank() |+| StringRules.maxLength(100)) 45 | content <- c.downField("content").readOpt(StringRules.maxLength(settings.contentMaxLength)) 46 | contentType <- c.downField("content_type").read(StringRules.maxLength(200)) 47 | status <- c.downField("status").read(IntRules.min(100) |+| IntRules.max(999)) 48 | charset <- c.downField("charset").as[String] 49 | headers <- c.downField("headers").as[Option[CreateUpdateMockHeaders]] 50 | secret <- c.downField("secret").read(StringRules.maxLength(settings.secretMaxLength)) 51 | expiration <- c.downField("expiration").as[Expiration] 52 | } yield CreateUpdateMock(name, content, contentType, status, charset, headers, secret, expiration) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/mocks/actions/DeleteMock.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.mocks.actions 2 | 3 | import io.circe.Decoder 4 | 5 | final case class DeleteMock(secret: String) 6 | 7 | object DeleteMock { 8 | 9 | implicit val deleteMockDecoder: Decoder[DeleteMock] = Decoder.instance[DeleteMock] { c => 10 | for { 11 | secret <- c.downField("secret").as[String] 12 | } yield DeleteMock(secret) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/mocks/enums/Expiration.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.mocks.enums 2 | import scala.concurrent.duration._ 3 | 4 | import enumeratum._ 5 | 6 | sealed abstract class Expiration(override val entryName: String, val duration: Option[FiniteDuration]) extends EnumEntry 7 | 8 | object Expiration extends Enum[Expiration] with CirceEnum[Expiration] { 9 | 10 | override val values: IndexedSeq[Expiration] = findValues 11 | 12 | case object Never extends Expiration("never", None) 13 | case object `1day` extends Expiration("1day", Some(1.day)) 14 | case object `1week` extends Expiration("1week", Some(7.days)) 15 | case object `1month` extends Expiration("1month", Some(31.days)) 16 | case object `1year` extends Expiration("1year", Some(361.days)) 17 | } 18 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/models/mocks/feedbacks/MockCreated.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.models.mocks.feedbacks 2 | 3 | import java.util.UUID 4 | 5 | /** 6 | * Mock have been created with success 7 | * @param id auto-generated identifier of the mock 8 | */ 9 | final case class MockCreated(id: UUID) 10 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/repositories/MockV2Repository.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.repositories 2 | 3 | import scala.annotation.nowarn 4 | 5 | import cats.effect.IO 6 | import com.typesafe.scalalogging.StrictLogging 7 | import doobie._ 8 | import doobie.implicits._ 9 | import doobie.implicits.javasql._ 10 | import doobie.postgres.circe.jsonb.implicits._ 11 | import doobie.util.log.LogHandler 12 | 13 | import io.mocky.db.DoobieLogHandler 14 | import io.mocky.http.middleware.Admin 15 | import io.mocky.models.Gate 16 | import io.mocky.models.admin.Stats 17 | import io.mocky.models.errors.MockNotFoundError 18 | import io.mocky.models.mocks.{ Mock, MockResponse } 19 | import io.mocky.utils.DateUtil 20 | 21 | /** 22 | * Load legacy mocks from PG, that have been migrated from MongoDB 23 | */ 24 | class MockV2Repository(transactor: Transactor[IO]) extends DoobieLogHandler with StrictLogging { 25 | 26 | implicit val log: LogHandler = doobieLogHandler 27 | 28 | private object SQL { 29 | private val TABLE = Fragment.const("mocks_v2") 30 | 31 | def GET(id: String): Fragment = 32 | fr"SELECT content, status, content_type, charset, headers FROM $TABLE WHERE id = $id" 33 | 34 | def UPDATE_STATS(id: String): Fragment = 35 | fr"UPDATE $TABLE SET last_access_at = ${DateUtil.now}, total_access = total_access + 1 WHERE id = $id" 36 | 37 | val ADMIN_STATS: Fragment = 38 | fr""" 39 | SELECT 40 | COUNT(*) as nb_mocks, 41 | SUM(total_access) as total_access, 42 | SUM(case when last_access_at > NOW() - INTERVAL '1 MONTH' then 1 else 0 end) as nb_mocks_accessed_in_month, 43 | -1 as nb_mocks_created_in_month, 44 | SUM(case when last_access_at IS NULL then 1 else 0 end) as nb_mocks_never_accessed, 45 | SUM(case when last_access_at IS NULL OR last_access_at < NOW() - INTERVAL '1 YEAR' then 1 else 0 end) as nb_mocks_not_accessed_in_year, 46 | -1 as nb_distinct_ips, 47 | ROUND(AVG(OCTET_LENGTH(content))) as mock_average_length 48 | FROM $TABLE 49 | """ 50 | } 51 | 52 | /** 53 | * Fetch a legacy mock by its primary key, and update its stats 54 | */ 55 | def touchAndGetMockResponse(id: String): IO[Either[MockNotFoundError.type, MockResponse]] = { 56 | val queries = for { 57 | mock <- SQL.GET(id).query[Mock].option 58 | _ <- SQL.UPDATE_STATS(id).update.run 59 | } yield mock 60 | 61 | queries.transact(transactor).map { 62 | case Some(mock) => Right(MockResponse(mock)) 63 | case None => Left(MockNotFoundError) 64 | } 65 | } 66 | 67 | /** 68 | * Return some global statistics about V3 mocks 69 | * @param admin Gate to restrict this action to admin only 70 | */ 71 | def adminStats()(implicit @nowarn admin: Gate[Admin.type]): IO[Stats] = { 72 | SQL.ADMIN_STATS.query[Stats].unique.transact(transactor) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/services/DesignerService.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.services 2 | 3 | import cats.effect.IO 4 | import org.http4s.{ Headers, HttpRoutes, Response, Status, Uri } 5 | import org.http4s.dsl.Http4sDsl 6 | import org.http4s.headers.Location 7 | 8 | import io.mocky.config.CorsSettings 9 | 10 | class DesignerService(corsSettings: CorsSettings) extends Http4sDsl[IO] { 11 | 12 | val routes = HttpRoutes.of[IO] { 13 | case GET -> Root => 14 | IO.pure( 15 | Response[IO]( 16 | Status.MovedPermanently, 17 | headers = Headers.of( 18 | Location(Uri.unsafeFromString(corsSettings.domain)) 19 | ) 20 | )) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/services/MockAdminApiService.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.services 2 | 3 | import cats.effect.IO 4 | import io.circe.Json 5 | import org.http4s._ 6 | import org.http4s.dsl.Http4sDsl 7 | import io.circe.syntax._ 8 | 9 | import io.mocky.config.Settings 10 | import io.mocky.http.JsonMarshalling 11 | import io.mocky.http.middleware._ 12 | import io.mocky.models.Gate 13 | import io.mocky.repositories.{ MockV2Repository, MockV3Repository } 14 | 15 | class MockAdminApiService(repoV2: MockV2Repository, repoV3: MockV3Repository, settings: Settings) 16 | extends Http4sDsl[IO] 17 | with JsonMarshalling { 18 | 19 | // Expose the routes wrapped into their middleware 20 | lazy val routing: HttpRoutes[IO] = new Authorization(settings.admin).Administrator(routes) 21 | 22 | private def routes: AuthedRoutes[Role, IO] = AuthedRoutes.of[Role, IO] { 23 | 24 | // Delete an existing mock 25 | case DELETE -> Root / "api" / UUIDVar(id) as (user: Admin.type) => 26 | implicit val adminGate = Gate(user) 27 | for { 28 | deleted <- repoV3.adminDelete(id) 29 | response <- if (deleted) NoContent() else NotFound() 30 | } yield response 31 | 32 | // Fetch global statistics 33 | case GET -> Root / "api" / "stats" as (user: Admin.type) => 34 | implicit val adminGate = Gate(user) 35 | for { 36 | statsV2 <- repoV2.adminStats() 37 | statsV3 <- repoV3.adminStats() 38 | response <- Ok( 39 | Json.obj( 40 | "v2" -> statsV2.asJson, 41 | "v3" -> statsV3.asJson 42 | )) 43 | } yield response 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/services/MockApiService.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.services 2 | 3 | import cats.effect.IO 4 | import io.circe.Decoder 5 | 6 | import io.mocky.http.JsonMarshalling 7 | import io.mocky.models.errors.MockNotFoundError 8 | import io.mocky.models.mocks.actions.{ CreateUpdateMock, DeleteMock } 9 | import org.http4s._ 10 | import org.http4s.dsl.Http4sDsl 11 | import org.http4s.server.middleware.CORS 12 | 13 | import io.mocky.config.Settings 14 | import io.mocky.models.mocks.MockCreatedResponse 15 | import io.mocky.repositories.MockV3Repository 16 | import io.mocky.utils.HttpUtil 17 | 18 | class MockApiService(repository: MockV3Repository, settings: Settings) extends Http4sDsl[IO] with JsonMarshalling { 19 | 20 | // Allow only request coming from `settings.cors.domain` and `settings.cors.devDomain` if env == dev 21 | private val corsAPIConfig = { 22 | val allowedDevDomains = if (settings.environment == "dev") settings.cors.devDomains else None 23 | val allowedDomains = Seq(settings.cors.domain) ++ allowedDevDomains.getOrElse(Nil) 24 | CORS.DefaultCORSConfig.copy( 25 | anyOrigin = false, 26 | allowedOrigins = origin => allowedDomains.contains(origin) 27 | ) 28 | } 29 | 30 | // Expose the routes wrapped into their middleware 31 | lazy val routing: HttpRoutes[IO] = CORS(routes, corsAPIConfig) 32 | 33 | // Prepare a decoder with dynamic configuration 34 | implicit private val createUpdateMockDecoder: Decoder[CreateUpdateMock] = CreateUpdateMock.decoder(settings.mock) 35 | 36 | private def routes: HttpRoutes[IO] = HttpRoutes.of[IO] { 37 | 38 | // Create new mock 39 | case req @ POST -> Root / "api" / "mock" => 40 | decodeJson[IO, CreateUpdateMock](req) { createMock => 41 | for { 42 | created <- repository.insert(createMock.withIp(HttpUtil.getIP(req))) 43 | mock = MockCreatedResponse(created, createMock, settings.endpoint) 44 | response <- Created(mock) 45 | } yield response 46 | } 47 | 48 | // Get an existing mock 49 | case GET -> Root / "api" / "mock" / UUIDVar(id) => 50 | repository.get(id).flatMap { 51 | case Left(MockNotFoundError) => NotFound() 52 | case Right(mock) => Ok(mock) 53 | } 54 | 55 | // Get the stats of a mock 56 | case GET -> Root / "api" / "mock" / UUIDVar(id) / "stats" => 57 | repository.stats(id).flatMap { 58 | case Left(MockNotFoundError) => NotFound() 59 | case Right(stats) => Ok(stats) 60 | } 61 | 62 | // Update an existing mock 63 | case req @ PUT -> Root / "api" / "mock" / UUIDVar(id) => 64 | decodeJson[IO, CreateUpdateMock](req) { updateMock => 65 | for { 66 | updated <- repository.update(id, updateMock.withIp(HttpUtil.getIP(req))) 67 | response <- if (updated) NoContent() else NotFound() 68 | } yield response 69 | } 70 | 71 | // Delete an existing mock 72 | case req @ DELETE -> Root / "api" / "mock" / UUIDVar(id) => 73 | decodeJson[IO, DeleteMock](req) { deleteMock => 74 | for { 75 | deleted <- repository.delete(id, deleteMock) 76 | response <- if (deleted) NoContent() else NotFound() 77 | } yield response 78 | } 79 | 80 | // Check if a mock can be deleted with this secret 81 | case req @ POST -> Root / "api" / "mock" / UUIDVar(id) / "check" => 82 | decodeJson[IO, DeleteMock](req) { deleteMock => 83 | for { 84 | result <- repository.checkDeletionSecret(id, deleteMock) 85 | response <- Ok(result) 86 | } yield response 87 | } 88 | 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/services/MockRunnerService.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.services 2 | 3 | import cats.effect.{ IO, Timer } 4 | import org.http4s._ 5 | import org.http4s.dsl.Http4sDsl 6 | import org.http4s.server.middleware.CORS 7 | 8 | import io.mocky.config.Settings 9 | import io.mocky.http.HttpMockResponse 10 | import io.mocky.http.middleware.{ Jsonp, Sleep } 11 | import io.mocky.models.errors.MockNotFoundError 12 | import io.mocky.repositories.{ MockV2Repository, MockV3Repository } 13 | 14 | /** 15 | * Play V2 and V3 mocks 16 | */ 17 | class MockRunnerService(repoV2: MockV2Repository, repoV3: MockV3Repository, settings: Settings)(implicit 18 | timer: Timer[IO]) 19 | extends Http4sDsl[IO] 20 | with HttpMockResponse { 21 | 22 | // Expose the routes wrapped into their middleware 23 | val routing: HttpRoutes[IO] = CORS(new Sleep(settings.sleep)(Jsonp(routes))) 24 | 25 | private def routes: HttpRoutes[IO] = HttpRoutes.of[IO] { 26 | // Fetch and play a legacy mock 27 | case _ -> "v2" /: id /: _ => 28 | repoV2.touchAndGetMockResponse(id).flatMap { 29 | case Left(MockNotFoundError) => NotFound() 30 | case Right(mock) => respondWithMock(mock) 31 | } 32 | 33 | // Fetch and play a "last version" mock 34 | case _ -> "v3" /: UUIDVar(id) /: _ => 35 | repoV3.touchAndGetMockResponse(id).flatMap { 36 | case Left(MockNotFoundError) => NotFound() 37 | case Right(mock) => respondWithMock(mock) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/services/StatusService.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.services 2 | 3 | import buildinfo.BuildInfo 4 | import cats.effect.IO 5 | import io.circe.Json 6 | import io.circe.syntax._ 7 | import org.http4s.HttpRoutes 8 | import org.http4s.circe._ 9 | import org.http4s.dsl.Http4sDsl 10 | 11 | class StatusService() extends Http4sDsl[IO] { 12 | 13 | val routes = HttpRoutes.of[IO] { 14 | case GET -> Root / "api" / "status" => 15 | Ok( 16 | Json.obj( 17 | "name" -> BuildInfo.name.asJson, 18 | "version" -> BuildInfo.version.asJson, 19 | "build_at" -> BuildInfo.builtAtString.asJson 20 | )) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/utils/DateUtil.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.utils 2 | 3 | import java.sql.Timestamp 4 | import java.time.{ LocalDateTime, ZoneId, ZonedDateTime } 5 | import scala.concurrent.duration.FiniteDuration 6 | 7 | object DateUtil { 8 | private val UTC = ZoneId.of("UTC") 9 | 10 | def now: Timestamp = Timestamp.valueOf(LocalDateTime.now()) 11 | 12 | def future(period: FiniteDuration): ZonedDateTime = { 13 | ZonedDateTime.now().plusDays(period.toDays) 14 | } 15 | 16 | def toTimestamp(zdt: ZonedDateTime) = Timestamp.valueOf(zdt.withZoneSameInstant(UTC).toLocalDateTime) 17 | } 18 | -------------------------------------------------------------------------------- /server/src/main/scala/io/mocky/utils/HttpUtil.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.utils 2 | 3 | import org.http4s.Request 4 | import org.http4s.util.CaseInsensitiveString 5 | 6 | object HttpUtil { 7 | 8 | private val SOURCE_IP_1 = CaseInsensitiveString("X-Real-Ip") 9 | private val SOURCE_IP_2 = CaseInsensitiveString("X-Forwarded-For") 10 | private val SOURCE_IP_3 = CaseInsensitiveString("HTTP_CLIENT_IP") 11 | private val SOURCE_IP_4 = CaseInsensitiveString("REMOTE_ADDR") 12 | 13 | def getIP[F[_]](req: Request[F]): String = { 14 | val headers = req.headers 15 | 16 | headers.get(SOURCE_IP_1).map(_.value) 17 | .orElse(headers.get(SOURCE_IP_2).map(_.value)) 18 | .orElse(headers.get(SOURCE_IP_3).map(_.value)) 19 | .orElse(headers.get(SOURCE_IP_4).map(_.value)) 20 | .getOrElse("0.0.0.0") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/test/scala/io/mocky/data/Fixtures.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.data 2 | 3 | import io.mocky.config._ 4 | import scala.concurrent.duration._ 5 | 6 | object Fixtures { 7 | 8 | val settings = Settings( 9 | environment = "prod", 10 | endpoint = "https://run.mocky.io", 11 | mock = MockSettings(1000000, 1000), 12 | security = SecuritySettings(14), 13 | throttle = ThrottleSettings(100, 1.seconds, 10000), 14 | sleep = SleepSettings(60.seconds, "mocky-delay"), 15 | cors = CorsSettings("mocky.io"), 16 | admin = AdminSettings("X-Auth-Token", "secret") 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /server/src/test/scala/io/mocky/http/middleware/JsonpSpec.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.http.middleware 2 | 3 | import cats.effect.IO 4 | import org.http4s._ 5 | import org.http4s.dsl.io._ 6 | import org.http4s.headers.`Content-Type` 7 | import org.scalamock.scalatest.MockFactory 8 | import org.scalatest.OptionValues 9 | import org.scalatest.matchers.should.Matchers 10 | import org.scalatest.wordspec.AnyWordSpec 11 | 12 | class JsonpSpec extends AnyWordSpec with MockFactory with Matchers with OptionValues { 13 | 14 | "JSONP Filter" should { 15 | "accept callback with alphanumeric and some special characters" in { 16 | val callbacks = Seq( 17 | "$jQuery_1234-5678", 18 | "callback" 19 | ) 20 | val originalBody = "alert('hello');" 21 | 22 | callbacks.foreach { callback => 23 | val server = serveJsonp(originalBody) 24 | 25 | val request = Request[IO](GET, Uri.unsafeFromString(s"/?callback=$callback")) 26 | val response = server(request).unsafeRunSync() 27 | 28 | val body = response.as[String].unsafeRunSync() 29 | val status = response.status 30 | val contentType: Option[`Content-Type`] = response.contentType 31 | 32 | body shouldBe s"$callback($originalBody);" 33 | status shouldBe Ok 34 | contentType.value.mediaType shouldBe MediaType.application.javascript 35 | } 36 | 37 | } 38 | } 39 | 40 | private def serveJsonp(body: String): Http[IO, IO] = { 41 | Jsonp(HttpApp[IO] { _ => Ok(body) }) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /server/src/test/scala/io/mocky/services/MockAPIServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.services 2 | 3 | import java.time.ZonedDateTime 4 | import java.util.UUID 5 | 6 | import cats.effect.IO 7 | import io.circe.Json 8 | import io.circe.literal._ 9 | import io.circe.syntax._ 10 | import io.circe.optics.JsonPath._ 11 | import org.http4s.circe._ 12 | import org.http4s.dsl.io._ 13 | import org.http4s.implicits._ 14 | import org.http4s.{ Request, Response, Status, Uri } 15 | import org.scalamock.scalatest.MockFactory 16 | import org.scalatest.OptionValues 17 | import org.scalatest.matchers.should.Matchers 18 | import org.scalatest.wordspec.AnyWordSpec 19 | 20 | import io.mocky.data.Fixtures 21 | import io.mocky.models.mocks.actions.CreateUpdateMock 22 | import io.mocky.models.mocks.actions.CreateUpdateMock.CreateUpdateMockHeaders 23 | import io.mocky.models.mocks.enums.Expiration 24 | import io.mocky.models.mocks.feedbacks.MockCreated 25 | import io.mocky.repositories.MockV3Repository 26 | 27 | class MockAPIServiceSpec extends AnyWordSpec with MockFactory with Matchers with OptionValues { 28 | 29 | private val repository = stub[MockV3Repository] 30 | private val settings = Fixtures.settings 31 | private val service = new MockApiService(repository, settings) 32 | private val routes = service.routing 33 | 34 | "Mock API Service" should { 35 | 36 | val id = java.util.UUID.randomUUID() 37 | 38 | val mock = CreateUpdateMock( 39 | Some("My name is Mocky"), 40 | Some("Hello World"), 41 | "text/plain", 42 | 200, 43 | "UTF-8", 44 | Some(CreateUpdateMockHeaders(Map("X-FOO" -> "bar"))), 45 | "secret", 46 | Expiration.`1day`, 47 | Some("0.0.0.0") 48 | ) 49 | 50 | val createJson = 51 | json""" 52 | { 53 | "name": ${mock.name}, 54 | "content": ${mock.content}, 55 | "content_type": ${mock.contentType}, 56 | "charset": ${mock.charset}, 57 | "status": ${mock.status}, 58 | "headers": ${mock.headers.get.underlyingMap.asJson}, 59 | "secret": ${mock.secret}, 60 | "expiration": ${mock.expiration.entryName} 61 | }""" 62 | 63 | "create a mock" in { 64 | (repository.insert _).when(*).returns(IO.pure(MockCreated(id))) 65 | val response = serve(Request[IO](POST, uri"/api/mock").withEntity(createJson)) 66 | response.status shouldBe Status.Created 67 | 68 | val json = response.as[Json].unsafeRunSync() 69 | root.id.as[UUID].getOption(json).value shouldBe id 70 | root.secret.as[String].getOption(json).value shouldBe mock.secret 71 | root.link.as[String].getOption(json).value shouldBe settings.endpoint + "/v3/" + id 72 | 73 | root.expireAt.as[ZonedDateTime].getOption(json).value.isAfter(ZonedDateTime.now().plusDays(1).minusMinutes(1)) 74 | root.expireAt.as[ZonedDateTime].getOption(json).value.isBefore(ZonedDateTime.now().plusDays(1).plusMinutes(1)) 75 | } 76 | 77 | "create a mock with an empty content" in { 78 | (repository.insert _).when(*).returns(IO.pure(MockCreated(id))) 79 | val json = createJson.hcursor.downField("content").delete.top.get 80 | val response = serve(Request[IO](POST, uri"/api/mock").withEntity(json)) 81 | response.status shouldBe Status.Created 82 | 83 | val jsonR = response.as[Json].unsafeRunSync() 84 | root.id.as[UUID].getOption(jsonR).value shouldBe id 85 | } 86 | 87 | "reject mock with invalid io.mocky.http status" in { 88 | (repository.insert _).when(*).returns(IO.pure(MockCreated(id))) 89 | val json = createJson.hcursor.downField("status").set(0.asJson).top.get 90 | 91 | val response = serve(Request[IO](POST, uri"/api/mock").withEntity(json)) 92 | 93 | response.status shouldBe Status.UnprocessableEntity 94 | response.as[Json].unsafeRunSync() shouldBe json"""{ "errors": { ".status": "error.min.size" } }""" 95 | } 96 | 97 | "reject mock with too long content" in { 98 | (repository.insert _).when(*).returns(IO.pure(MockCreated(id))) 99 | val json = createJson.hcursor.downField("content").withFocus(_.mapString(_ => 100 | "*" * (settings.mock.contentMaxLength + 1))).top.get 101 | 102 | val response = serve(Request[IO](POST, uri"/api/mock").withEntity(json)) 103 | 104 | response.status shouldBe Status.UnprocessableEntity 105 | response.as[Json].unsafeRunSync() shouldBe json"""{ "errors": { ".content": "error.maximum.length" } }""" 106 | } 107 | 108 | "reject mock with too long secret" in { 109 | (repository.insert _).when(*).returns(IO.pure(MockCreated(id))) 110 | val json = createJson.hcursor.downField("secret").withFocus(_.mapString(_ => 111 | "*" * (settings.mock.secretMaxLength + 1))).top.get 112 | 113 | val response = serve(Request[IO](POST, uri"/api/mock").withEntity(json)) 114 | 115 | response.status shouldBe Status.UnprocessableEntity 116 | response.as[Json].unsafeRunSync() shouldBe json"""{ "errors": { ".secret": "error.maximum.length" } }""" 117 | } 118 | 119 | "update an existing mock" in { 120 | (repository.update _).when(id, mock).returns(IO.pure(true)) 121 | val response = serve(Request[IO](PUT, Uri.unsafeFromString(s"/api/mock/$id")).withEntity(createJson)) 122 | response.status shouldBe Status.NoContent 123 | } 124 | 125 | "refuse to update a mock if the secret is invalid an existing mock" in { 126 | (repository.update _).when(id, *).returns(IO.pure(false)) 127 | val response = serve(Request[IO](PUT, Uri.unsafeFromString(s"/api/mock/$id")).withEntity(createJson)) 128 | response.status shouldBe Status.NotFound 129 | } 130 | 131 | "delete an existing mock" in { 132 | (repository.delete _).when(id, *).returns(IO.pure(true)) 133 | val response = serve(Request[IO](DELETE, Uri.unsafeFromString(s"/api/mock/$id")).withEntity(createJson)) 134 | response.status shouldBe Status.NoContent 135 | } 136 | } 137 | 138 | private def serve(request: Request[IO]): Response[IO] = { 139 | routes.orNotFound(request).unsafeRunSync() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /server/src/test/scala/io/mocky/services/MockRunnerServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package io.mocky.services 2 | 3 | import cats.effect.IO 4 | import io.circe.Json 5 | import io.circe.literal._ 6 | import org.http4s._ 7 | import org.http4s.circe._ 8 | import org.http4s.dsl.io._ 9 | import org.http4s.headers._ 10 | import org.http4s.implicits._ 11 | import org.scalamock.scalatest.MockFactory 12 | import org.scalatest.matchers.should.Matchers 13 | import org.scalatest.wordspec.AnyWordSpec 14 | 15 | import io.mocky.data.Fixtures 16 | import io.mocky.http.middleware.Jsonp 17 | import io.mocky.models.mocks.MockResponse 18 | import io.mocky.repositories.{ MockV2Repository, MockV3Repository } 19 | 20 | class MockRunnerServiceSpec extends AnyWordSpec with MockFactory with Matchers { 21 | implicit val ec = scala.concurrent.ExecutionContext.global 22 | implicit val timer = IO.timer(ec) 23 | 24 | private val repositoryV2 = stub[MockV2Repository] 25 | private val repositoryV3 = stub[MockV3Repository] 26 | private val settings = Fixtures.settings 27 | private val service = new MockRunnerService(repositoryV2, repositoryV3, settings) 28 | private val routes = service.routing 29 | 30 | "Mock Runner" should { 31 | 32 | val id = java.util.UUID.randomUUID() 33 | val content = json"""{"hello":"world"}""".noSpaces 34 | val header = Header("X-Foo-Bar", "HelloWorld") 35 | val mock = MockResponse( 36 | content = Some(content.getBytes(MockResponse.DEFAULT_CHARSET.nioCharset)), 37 | charset = MockResponse.DEFAULT_CHARSET, 38 | status = Status.Created, 39 | contentType = MockResponse.DEFAULT_CONTENT_TYPE.withCharset(MockResponse.DEFAULT_CHARSET), 40 | headers = Headers.of(header) 41 | ) 42 | 43 | val verbs: Seq[Method] = Seq(GET, POST, PUT, PATCH, DELETE, TRACE) 44 | 45 | verbs.foreach { verb => 46 | s"play a mock with $verb request" in { 47 | (repositoryV3.touchAndGetMockResponse _).when(id).returns(IO.pure(Right(mock))) 48 | val response = serve(Request[IO](verb, Uri.unsafeFromString(s"/v3/$id"))) 49 | 50 | response.status shouldBe Status.Created 51 | response.as[Json].map(_.noSpaces).unsafeRunSync() shouldBe content 52 | response.headers.get(header.name) shouldBe Some(header) 53 | } 54 | } 55 | 56 | "support the sleep feature" in { 57 | (repositoryV3.touchAndGetMockResponse _).when(id).returns(IO.pure(Right(mock))) 58 | 59 | val delay = 1500L 60 | val start = System.currentTimeMillis() 61 | val _ = serve(Request[IO](GET, Uri.unsafeFromString(s"/v3/$id?${settings.sleep.parameter}=${delay}ms"))) 62 | val end = System.currentTimeMillis() 63 | 64 | (end - start) shouldBe (delay +- 100) 65 | } 66 | 67 | "support the callback feature" in { 68 | (repositoryV3.touchAndGetMockResponse _).when(id).returns(IO.pure(Right(mock))) 69 | 70 | val response = serve(Request[IO](GET, Uri.unsafeFromString(s"/v3/$id?${Jsonp.DEFAULT_PARAMETER}=wrapInside"))) 71 | 72 | response.status shouldBe Status.Created 73 | response.as[String].unsafeRunSync() shouldBe s"wrapInside($content);" 74 | response.headers.get(`Content-Type`) shouldBe Some(`Content-Type`(MediaType.application.javascript)) 75 | } 76 | 77 | "support CORS request coming from any origin, any header, etc" in { 78 | 79 | (repositoryV3.touchAndGetMockResponse _).when(id).returns(IO.pure(Right(mock))) 80 | 81 | val origin = "http://www.anywebsite.fr" 82 | val corsRequestHeaders = Seq( 83 | `Origin`.parse(origin), 84 | `Access-Control-Request-Method`.parse("DELETE"), 85 | `Access-Control-Request-Headers`.parse("X-FOO-BAR, Authorization2") 86 | ).map(_.toOption.get) 87 | 88 | def headersMap(response: Response[IO]) = response.headers.toList.view.map(h => h.name -> h.value).to(Map) 89 | 90 | // Check preflight flow 91 | { 92 | val preflightRequest = Request[IO](OPTIONS, Uri.unsafeFromString(s"/v3/$id")) 93 | .withHeaders(corsRequestHeaders: _*) 94 | val response = serve(preflightRequest) 95 | val headers = headersMap(response) 96 | 97 | response.status shouldBe Status.Ok 98 | 99 | headers("Access-Control-Allow-Origin".ci) shouldBe origin 100 | headers("Access-Control-Allow-Methods".ci) shouldBe "DELETE" 101 | headers("Access-Control-Allow-Headers".ci).contains("*") shouldBe true 102 | headers("Access-Control-Max-Age".ci) should not be empty 103 | } 104 | 105 | // Check main flow 106 | { 107 | val request = Request[IO](DELETE, Uri.unsafeFromString(s"/v3/$id")).withEntity("Hello") 108 | .withHeaders(corsRequestHeaders: _*) 109 | val response = serve(request) 110 | val headers = headersMap(response) 111 | 112 | response.status shouldBe Status.Created 113 | 114 | headers("Access-Control-Allow-Origin".ci) shouldBe origin 115 | headers("X-FOO-BAR".ci) shouldBe header.value 116 | headers("Content-Type".ci) shouldBe mock.contentType.value 117 | 118 | } 119 | 120 | } 121 | 122 | } 123 | 124 | private def serve(request: Request[IO]): Response[IO] = { 125 | routes.orNotFound(request).unsafeRunSync() 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /server/stack/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | postgres: 5 | container_name: mocky2020_postgres 6 | image: postgres 7 | environment: 8 | POSTGRES_USER: mocky 9 | POSTGRES_PASSWORD: mocky 10 | POSTGRES_DB: mocky 11 | ports: 12 | - "5432:5432" 13 | networks: 14 | - mocky 15 | restart: unless-stopped 16 | 17 | pgadmin: 18 | container_name: mocky2020_pgadmin4 19 | image: dpage/pgadmin4 20 | environment: 21 | PGADMIN_DEFAULT_EMAIL: pgadmin4@pgadmin.org 22 | PGADMIN_DEFAULT_PASSWORD: admin 23 | ports: 24 | - "5050:80" 25 | networks: 26 | - mocky 27 | volumes: 28 | - ./servers.json:/pgadmin4/servers.json 29 | restart: unless-stopped 30 | 31 | networks: 32 | mocky: 33 | driver: bridge 34 | -------------------------------------------------------------------------------- /server/stack/servers.json: -------------------------------------------------------------------------------- 1 | { 2 | "Servers": { 3 | "1": { 4 | "Name": "Mocky", 5 | "Group": "Docker", 6 | "Port": 5432, 7 | "Username": "mocky", 8 | "Host": "postgres", 9 | "SSLMode": "prefer", 10 | "MaintenanceDB": "postgres", 11 | "Comment": "Password is 'mocky'" 12 | } 13 | } 14 | } --------------------------------------------------------------------------------