diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..60d73160 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 88 +exclude = .git,__pycache__ +max-complexity = 10 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c8bd8469..32d1eb98 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ __pycache__/ *.db .DS_Store venv/ -.venv/ \ No newline at end of file +.venv/ +docs/docs/.nota/config.ini diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..9fbfe5af --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Flask-Smorest Docker"] + path = project/using-flask-smorest-docker + url = https://github.com/tecladocode/rest-api-smorest-docker \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..30291cba --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10.0 diff --git a/.templates/lecture.md b/.templates/lecture.md new file mode 100644 index 00000000..9401fabe --- /dev/null +++ b/.templates/lecture.md @@ -0,0 +1,17 @@ +--- +title: The lecture title goes here +description: A brief description of the lecture goes here. +--- + +- [ ] Set metadata above +- [ ] Start writing! +- [ ] Create `start` folder +- [ ] Create `end` folder +- [ ] Write TL;DR +- [ ] Create per-file diff between `end` and `start` (use "Compare Folders") + + + +# Lecture Title + + diff --git a/.templates/section.md b/.templates/section.md new file mode 100644 index 00000000..b7cd21e1 --- /dev/null +++ b/.templates/section.md @@ -0,0 +1,7 @@ +--- +name: "Section name here" +--- + +# Section name here + +Description of the section goes here. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..9d40bbe8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# How to contribute to this course + +## E-book contributions + +### How to run the e-book + +Clone the repo and navigate to the `docs` folder. + +There, run: + +``` +npm install +``` + +Then you can run the e-book with: + +``` +npm run start +``` + +If you make any changes to the e-book, please keep changes as simple as possible and create a PR with your changes into the `develop` branch. + +If you are making larger changes, please create a Discussion first and let's talk about it! + +### Making changes to projects + +All the finished projects that we cover in the course are in the `projects` folder. Making changes to these projects is done very carefully, especially after recording. + +Please start a Discussion before making any changes, as doing so can make the experience for students confusing (if the videos and e-book are different). diff --git a/README.md b/README.md index 271c75c0..9806d509 100644 --- a/README.md +++ b/README.md @@ -36,54 +36,42 @@ If you do this for the entire course, I guarantee you will learn how to make RES ## Section 2: A Full Python Refresher -The code is in files numbered between 1 and 11, covering concepts ranging from beginner to advanced. - -1. Variables -2. Methods -3. Lists, tuples, and sets -4. If statements -5. List comprehension -6. Dictionaries -7. Classes and objects -8. Static and class methods -9. Args and Kwargs -10. Passing functions as arguments -11. Decorators +This section (only available on Udemy) helps programmers who are new to Python get acquainted with the language. It is not a complete-beginner Python course! ## Section 3: Your first REST API -The code in this section includes a simple Flask app and a HTML and JavaScript file which calls the Flask app endpoints. +The code in this section includes a simple Flask app that accepts and returns JSON data. -## Section 4: Flask-RESTful +## Section 4: Docker -The code in this section includes a Flask app which is an API that represents items. It also includes user registration and authentication. +Introduction to Docker to run your REST APIs. We talk about images, containers, and how to run applications. -We also introduce Flask-RESTful, which is a Flask extension that helps us develop APIs more easily. +## Section 5: Flask-Smorest -## Section 5: Working with SQL - -The code in this section extends the last section by adding persistent storage of Items to a SQLite database. +We introduce the Flask-Smorest extension, a library that greatly simplifies writing REST APIs using Flask. It also provides things like automated documentation generation. ## Section 6: Flask-SQLAlchemy -The code in this section extends the previous section by replacing the manual integration with SQLite, with SQLAlchemy—an ORM (Object-Relational Mapping)—which allows us to easily replace SQLite with something like PostgreSQL or MySQL. +The code in this section extends the previous section by replacing the data storage in Python lists with SQLAlchemy, an ORM (Object-Relational Mapping which simplifies connecting to and interacting with a database. + +## Section 7: Many-to-many relationships -## Section 7: Git for version control +In this section we talk about many-to-many relationships using SQLAlchemy. -In this section we introduce Git, a tool for code sharing and collaboration. In this course we'll use it to store the application code and then send it to our deployment tools, Heroku and DigitalOcean. +## Section 8: Authentication with Flask-JWT-Extended -## Section 8: Deploying Flask Apps to Heroku +Learn how to perform user authentication using JWTs and the Flask-JWT-Extended library. Here we talk about access token JWTs, as well as refresh tokens, JWT claims, blocklists, password hashing, and more. -Learn how to use GitHub and Heroku to deploy your Flask applications and make them available publicly to your users. +## Section 9: Flask-Migrate -## Section 9: Deploying Flask Apps to our own servers +After deploying your apps, making changes to the database can be really tricky because you have to log in to the database server and manually update the database tables using SQL commands. -Learn how to rent a server using DigitalOcean and run our Flask app in it. This is an alternative to Heroku. It's much cheaper, but requires a lot more work to get it set up. +Flask-Migrate and the Alembic libraries simplify this job by creating migration scripts. -## Section 10: Security in your REST APIs +## Section 10: Git Crash Course -In this section we learn about https and how to enable it in your own server running with DigitalOcean. +A quick and intense course on Git and GitHub for code sharing. -## Section 11: Token Refreshing and Flask-JWT-Extended +## Section 11: Deploying to Render.com -Learn about token freshness and how to implement refresh tokens using Flask-JWT-Extended. +Learn how to get your code running in the cloud and make it publicly accessible. In this section we use Render.com for deployments and we also deploy a PostgreSQL database. \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..b2d6de30 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +/node_modules + +# Production +/build + +# Generated files +.docusaurus +.cache-loader + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..e6bf3541 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# Website + +This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. + +### Installation + +``` +$ npm install +``` + +### Local Development + +``` +$ npm run start +``` + +This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. + +### Build + +``` +$ npm run build +``` + +This command generates static content into the `build` directory and can be served using any static contents hosting service. + +### Deployment + +Using SSH: + +``` +$ USE_SSH=true npm run deploy +``` + +Not using SSH: + +``` +$ GIT_USER= npm run deploy +``` + +If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. diff --git a/docs/babel.config.js b/docs/babel.config.js new file mode 100644 index 00000000..e00595da --- /dev/null +++ b/docs/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [require.resolve('@docusaurus/core/lib/babel/preset')], +}; diff --git a/docs/docs-upcoming/11_celery_background_tasks/01_project_overview/README.md b/docs/docs-upcoming/11_celery_background_tasks/01_project_overview/README.md new file mode 100644 index 00000000..17d79a19 --- /dev/null +++ b/docs/docs-upcoming/11_celery_background_tasks/01_project_overview/README.md @@ -0,0 +1 @@ +# Project overview \ No newline at end of file diff --git a/docs/docs-upcoming/11_celery_background_tasks/README.md b/docs/docs-upcoming/11_celery_background_tasks/README.md new file mode 100644 index 00000000..742f910c --- /dev/null +++ b/docs/docs-upcoming/11_celery_background_tasks/README.md @@ -0,0 +1 @@ +# Flask-Smorest for more efficient development \ No newline at end of file diff --git a/docs/docs-upcoming/11_celery_background_tasks/_category_.json b/docs/docs-upcoming/11_celery_background_tasks/_category_.json new file mode 100644 index 00000000..4af7a57b --- /dev/null +++ b/docs/docs-upcoming/11_celery_background_tasks/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Use Celery for Background Tasks", + "position": 12 +} diff --git a/docs/docs/01_course_intro/01_curriculum_overview/README.md b/docs/docs/01_course_intro/01_curriculum_overview/README.md new file mode 100644 index 00000000..d190291a --- /dev/null +++ b/docs/docs/01_course_intro/01_curriculum_overview/README.md @@ -0,0 +1,8 @@ +--- +title: Curriculum overview +description: A brief description of the lecture goes here. +--- + +# Curriculum overview + +The curriculum overview goes here. \ No newline at end of file diff --git a/docs/docs/01_course_intro/02_how_to_install_python/README.md b/docs/docs/01_course_intro/02_how_to_install_python/README.md new file mode 100644 index 00000000..9932713a --- /dev/null +++ b/docs/docs/01_course_intro/02_how_to_install_python/README.md @@ -0,0 +1,36 @@ +--- +title: How to install Python +description: A brief description of the lecture goes here. +--- + +# How to install Python on your computer + +In this lecture I'll guide you through installing Python on your computer. If have already installed Python, feel free to skip to the next lecture! + +## On Windows + +To install Python, download the latest version of Python from https://www.python.org. At the time of writing, that was Python 3.10.4. + +:::caution Add to PATH +As you go through the installer, make sure to check "Add Python to PATH". +::: + +Once Python is installed, you can execute the program `cmd.exe`. This is a command-line interface to your computer. Here, just type the word `python` and that will start the Python program. + +At all points during the course, you can always type `python name_of_file.py` and that will execute the code of a file called `name_of_file.py` + +If you have multiple versions of Python installed, such as a version you installed a while ago, you'll need to use the complete path to Python in order to run it. Usually it'll look something like this: + +``` +C:\\Users\\yourname\\AppData\\Local\\Programs\\Python\\Python39-32\\python.exe +``` + +When you use an IDE, such as [Visual Studio Code](../how_to_install_ide), you can use the integrated terminal instead of `cmd.exe`. + +## On Mac + +To install Python, download the latest version of Python from https://www.python.org. At the time of writing, that was Python 3.10.4. + +Once Python is installed, you can execute the program `Terminal.app`. This is a command-line interface to your computer. Here, just type the word `python3.9` and that will start the Python program. + +At all points during the course, you can always type `python3.9 name_of_file.py` and that will execute the code of a file called `name_of_file.py`. \ No newline at end of file diff --git a/docs/docs/01_course_intro/03_how_to_install_ide/README.md b/docs/docs/01_course_intro/03_how_to_install_ide/README.md new file mode 100644 index 00000000..1e5707d1 --- /dev/null +++ b/docs/docs/01_course_intro/03_how_to_install_ide/README.md @@ -0,0 +1,25 @@ +--- +title: How to install an IDE +description: What IDE should you use? How do you install it? Let me show you in this quick guide. +--- + +# How to install an IDE + +An IDE is an Integrated Development Environment. If you've got experience coding, I'm sure you've used an IDE at some point or another. + +IDEs are text editors that let you modify your code. However, as the name says, they do a bit more than just that. + +Often we can use IDEs to run our code, connect to databases, use a debugger, or a whole host of other things! + +Throughout this course I use Visual Studio Code. It's a very powerful IDE that you can get for free at https://code.visualstudio.com/. If you get VS Code, I've got a blog post on how to set it up for Python development: https://blog.tecladocode.com/how-to-set-up-visual-studio-code-for-python-development/ + +## Opening Projects + +Whenever you work using an IDE, you should open separate projects in separate windows: + +- 👍 When you start a section of the course, make a folder for that section and open it with VSCode. Now VSCode treats that as a "project" folder. +- 👎 Make a folder for the entire course and open it with VSCode. Inside it, make a folder for each section. VSCode will treat the top-level course folder as the "project", and your experience will be a bit more difficult. + +I've noticed some students like opening their "projects" folder with the IDE, so that they can work on all their projects in one window. This is likely to cause problems due to how Python looks for code files to use and import (more on that when you get to the "Imports" section of the Python Refresher!). + +So don't be afraid to have many different project folders, each one with their own virtual environment and dependencies. That's normal and will make it much easier to work with! \ No newline at end of file diff --git a/docs/docs/01_course_intro/04_what_is_rest_api/README.md b/docs/docs/01_course_intro/04_what_is_rest_api/README.md new file mode 100644 index 00000000..0dd5146a --- /dev/null +++ b/docs/docs/01_course_intro/04_what_is_rest_api/README.md @@ -0,0 +1,194 @@ +--- +title: "What is a REST API?" +description: "There's a lot of confusion around what is and isn't a REST API. Let's take a look!" +--- + +# What is a REST API? + +## What is an API? + +API stands for "Application Programming Interface", but that's not an overly helpful name! + +The most important part of the term is "Interface". Just as the interface to a car is the parts we humans interact with (steering wheel, pedals, gear stick), the interface to an application is the code that another application can interact with. + +This way, any part of an application that can be "called" or "executed" from another application, is part of that application's API. + +For example, let's say you make a simple Python library to save data to a database. This is what the library looks like: + +```py +def save_to_db(what_to_save): + pass + + +def get_from_db(query): + pass +``` + +Assume that the functions are implemented and they do something! + +This "library" has an API: the `save_to_db` and `get_from_db` functions. These are the functions that the library makes available to other programs (or parts of programs), which those other programs should use to save and get data from a database. + +If you look at it this way, almost everything in programming has at least an "interface". + +As another example, when you code a class, it has an interface: the public attributes and methods. + +So the key to an API is that it has to be publicly callable, and it allows the _client_ (whoever calls it) to interact with the program that offers the API. + +### An API with Flask + +When we make Flask apps, we also have some public functions that can be called. Our public functions are each associated to an endpoint, such as `/store`. + +That way a client (such as another Python program, or even a web browser) can access the `/store` endpoint of our application, and we can run some code and return a value. + +If our Flask app is hosted at `http://my-flask-app.com`, then accessing `http://my-flask-app.com/store` would execute the function associated with the `/store` endpoint in our app, and the client would receive the data returned by the associated function. + +That data might look like this: + +```json +{ + "stores": [ + { + "name": "My Store", + "items": [ + { + "name": "my item", + "price": 15.99 + } + ] + } + ] +} +``` + +### The purpose of APIs + +We've learned that we can make a Flask app and expose certain functions to the public by using endpoints. Clients can then make requests (we'll learn how later), and get data. + +Clients can also send data, which the Flask functions can use. + +But _why_? If you want to use certain functions, why not just code them in your application? + +There's one main reason: so two or more clients can use the API without having to duplicate the logic that the API offers inside their own code. + +Let's say you want to build a weather app. + +You could try to install sensors at the top of your house, connect them directly to the computer running your code, and then offer weather info based on what the sensors say... + +Or you could request weather data from the OpenWeatherMap API, just as tens of thousands of other devices do. + +Much easier, and all you have to do is make a request to the API! + +### What is a client? + +An API client can be any device, such as a web app or a mobile app. + +### Making an API for your own consumption + +Make software companies make APIs that only they use (so they aren't fully public). + +Here's an example. You're making a multiplayer mobile game, and you need to store information about the moves that your character is making. + +In your mobile app code, you could connect to a central database and store the moves there. Apps in other mobile devices would also connect to the central database and store (and read) the moves from there. + +But what happens when you want to expand your app to other devices? Let's say, iOS and Android? + +Then you've got to duplicate your database logic in two places: the two app codebases. The problem is compounded if you want to expand to computers, consoles, etc. + +It's easier to have an API which exposes certain functions that let your app save and retrieve data from a database, and have all your devices use that same API. + +It will be much simpler, and when you want to make database changes you most likely won't have to change the code of each mobile app. + +## What is REST? + +Now that you know what an API is, a slightly more difficult question to answer is "What is a REST API?". + +A REST API is just an API that follows specific conventions and has specific characteristics. + +REST APIs deal in resources, so every individual "thing" that can be named is a resource. For example, stores, items, tags, users, or less concrete things like temporal services or collections of other resources. + +The main characteristics (or constraints) of a REST API are: + +1. **Uniform interface**. Whichever way clients should access a certain resource should also be the way the access other resources. Clients should have a single way to retrieve resources. +2. **Client-server**. Clients should know the endpoints of the API, but they should not be coupled to the development of the API. A client or a servevr may be swapped out for a different implementation without the other noticing. +3. **Stateless**. The server (API) doesn't store anything about previous client requests. Each client request is treated as a brand new client. If the client needs the server to personalize the response, then the client must send the server whatever information the server needs in order to do so. +4. **Cacheable**. The client or server must be able to cache the resources returned by the API. This is a very general constraint, but it's an important one. +5. **Layered system**. REST APIs may be developed as multiple layers, where each layer interacts [only with the layer above and below it](https://excalidraw.com/#json=or3Umoigss4yIeuKg3cO8,qH6uDDCXc7DSjweqNvlmzw). + +If you'd like to read a very complete and exhaustive guide about everything that a REST API is, check out [this guide](https://restfulapi.net/). + +## The API we'll build in this course + +In this course we'll build a REST API to expose interactions with stores, items, tags, and users. The API will allow clients to: + +- Create and retrieve information about stores. +- Create, retrieve, search for, update, and delete items in those stores. +- Create tags and link them to items; and search for items with specific tags. +- Add user authentication to the client apps using the API. + +The API will have the endpoints shown below. + +:::info What do the locks mean? +It's usually important in APIs that only certain people have access to certain endpoints. For example, paying customers may have access to certain endpoints while free users may not. + +We'll deal with user authentication in a later section, but that's what the locks (🔒) mean below. + +- One 🔒 means the user will need to have authenticated within the last few days to make a request. +- Two 🔒🔒 means the user will need to have authenticated within the last few minutes to make a request. +- No locks means anybody can make a request. +::: + +### Users + +| Method | Endpoint | Description | +| -------------- | ----------------- | ----------------------------------------------------- | +| `POST` | `/register` | Create user accounts given an `email` and `password`. | +| `POST` | `/login` | Get a JWT given an `email` and `password`. | +| 🔒
`POST` | `/logout` | Revoke a JWT. | +| 🔒
`POST` | `/refresh` | Get a fresh JWT given a refresh JWT. | +| `GET` | `/user/{user_id}` | (dev-only) Get info about a user given their ID. | +| `DELETE` | `/user/{user_id}` | (dev-only) Delete a user given their ID. | + + +### Stores + +| Method | Endpoint | Description | +| ------ | ------------- | ---------------------------------------- | +| `GET` | `/store` | Get a list of all stores. | +| `POST` | `/store` | Create a store. | +| `GET` | `/store/{id}` | Get a single store, given its unique id. | +| `POST` | `/store/{id}` | Delete a store, given its unique id. | + +### Items + +| Method | Endpoint | Description | +| ---------------- | ------------ | --------------------------------------------------------------------------------------------------- | +| 🔒
`GET` | `/item` | Get a list of all items in all stores. | +| 🔒🔒
`POST` | `/item` | Create a new item, given its name and price in the body of the request. | +| 🔒
`GET` | `/item/{id}` | Get information about a specific item, given its unique id. | +| `PUT` | `/item/{id}` | Update an item given its unique id. The item name or price can be given in the body of the request. | +| 🔒
`DELETE` | `/item/{id}` | Delete an item given its unique id. | + +### Tags + +| Method | Endpoint | Description | +| -------- | --------------------- | ------------------------------------------------------- | +| `GET` | `/store/{id}/tag` | Get a list of tags in a store. | +| `POST` | `/store/{id}/tag` | Create a new tag. | +| `POST` | `/item/{id}/tag/{id}` | Link an item in a store with a tag from the same store. | +| `DELETE` | `/item/{id}/tag/{id}` | Unlink a tag from an item. | +| `GET` | `/tag/{id}` | Get information about a tag given its unique id. | +| `DELETE` | `/tag/{id}` | Delete a tag, which must have no associated items. | + +As you can see, we've got a lot to build! + +We'll start building REST APIs in section 3, "Your first REST API". Here we'll create a simpler version of the REST API detailed above, without tags or user authentication. + +Then, over the following sections, we'll improve on this REST API. We'll add: + +- Flask extensions to simplify our code. +- Use Docker to run the API more reliably. +- Use PostgreSQL for data storage. +- Add user authentication. +- Add item tagging. +- Add an admin panel so changing data manually is a bit easier. +- And much more! \ No newline at end of file diff --git a/docs/docs/01_course_intro/_category_.json b/docs/docs/01_course_intro/_category_.json new file mode 100644 index 00000000..49c4efa9 --- /dev/null +++ b/docs/docs/01_course_intro/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Course Introduction", + "position": 1 +} diff --git a/docs/docs/01_course_intro/index.md b/docs/docs/01_course_intro/index.md new file mode 100644 index 00000000..b0185d43 --- /dev/null +++ b/docs/docs/01_course_intro/index.md @@ -0,0 +1,42 @@ +--- +id: intro +--- + +# REST APIs with Flask and Python + +Hi, and welcome! + +REST APIs with Flask and Python is a complete course that teaches you how to develop complete, professional REST APIs using **Flask**, **PostgreSQL**, and **Docker**. + +In this website you can find the complete course notes and code. We made this to help students navigate the course more easily. Also, every single piece of code we write in the course is in the notes, with brief explanations. + +We've found that really helps review the course content later on! + +This is how we recommend taking the course: + +- Start at the first section of the course, and watch the videos. If you're comfortable, watch them in 1.25x or 1.5x speed! This will help you understand the content holistically. +- Then, re-watch the videos slowly while coding together with me. Write every piece of code by hand, just as you see it in the videos. By the time you're done with a section, after writing all the code, you'll be very comfortable with all the content in it. +- Move onto the next section, but keep the code from each section in a different folder. That way you can then look at the full project as it was at the end of each section, and that will help you review! + +I promise that if you follow this approach, you will master the content of this course within a few weeks. And you'll soon be able to code complete REST APIs using Flask and Python! + +:::tip Note-taking not required +Feel free to take notes while you go through the course, but you don't need to! + +This very website is a perfect set of notes for you to come back to weeks, months, or even years down the line to review what you learned in the course (and, let's be honest, find those snippets of code that you can just copy into your projects). +::: + +## Course Set-up and Housekeeping + +Below are some quick guides to get you started. Feel free to read through them if you need, or skip them! + +After this, we have a [Python Refresher](../02_python_refresher/index.md). If you are very comfortable with Python, feel free to skip that too! + +If you're skipping the Python Refresher, move onto Your First REST API. + +I'll see you there! + +import DocCardList from '@theme/DocCardList'; +import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; + + diff --git a/docs/docs/02_python_refresher/_category_.json b/docs/docs/02_python_refresher/_category_.json new file mode 100644 index 00000000..a72bf97e --- /dev/null +++ b/docs/docs/02_python_refresher/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Python Refresher", + "position": 2 +} diff --git a/docs/docs/02_python_refresher/index.md b/docs/docs/02_python_refresher/index.md new file mode 100644 index 00000000..b89af887 --- /dev/null +++ b/docs/docs/02_python_refresher/index.md @@ -0,0 +1,11 @@ +--- +name: "A Complete Python Refresher" +--- + +# Python Refresher + +We've included a Python Refresher section in this course to help you, in case you haven't done any Python for a while or you are coming from a different programming language. + +There are no written notes for this section, but all the videos are available in the Udemy course. + +If you are fully new to programming _and_ Python, this course may be a bit advanced. Feel free to read through out beginner Python e-book anyway, as it might be helpful: [https://python.tecladocode.com](https://python.tecladocode.com). diff --git a/docs/docs/03_first_rest_api/01_project_overview/README.md b/docs/docs/03_first_rest_api/01_project_overview/README.md new file mode 100644 index 00000000..67beae7f --- /dev/null +++ b/docs/docs/03_first_rest_api/01_project_overview/README.md @@ -0,0 +1,111 @@ +--- +title: Project Overview +description: A first look at the project we'll build in this section. +--- + +# Overview of your first REST API + +In this section we'll make a simple REST API that allows us to: + +- Create stores, each with a `name` and a list of stocked `items`. +- Create an item within a store, each with a `name` and a `price`. +- Retrieve a list of all stores and their items. +- Given its `name`, retrieve an individual store and all its items. +- Given a store `name`, retrieve only a list of item within it. + +This is how the interaction will go! + +## Create stores + +Request: + +``` +POST /store {"name": "My Store"} +``` + +Response: + +``` +{"name": "My Store", "items": []} +``` + +## Create items + +Request: + +``` +POST /store/My Store/item {"name": "Chair", "price": 175.50} +``` + +Response: + +``` +{"name": "Chair", "price": 175.50} +``` + +## Retrieve all stores and their items + +Request: + +``` +GET /store +``` + +Response: + +``` +{ + "stores": [ + { + "name": "My Store", + "items": [ + { + "name": "Chair", + "price": 175.50 + } + ] + } + ] +} +``` + +## Get a particular store + +Request: + +``` +GET /store/My Store +``` + +Response: + +``` +{ + "name": "My Store", + "items": [ + { + "name": "Chair", + "price": 175.50 + } + ] +} +``` + +## Get only items in a store + +Request: + +``` +GET /store/My Store/item +``` + +Response: + +``` +[ + { + "name": "Chair", + "price": 175.50 + } +] +``` \ No newline at end of file diff --git a/docs/docs/03_first_rest_api/02_getting_set_up/README.md b/docs/docs/03_first_rest_api/02_getting_set_up/README.md new file mode 100644 index 00000000..b6f0ff8d --- /dev/null +++ b/docs/docs/03_first_rest_api/02_getting_set_up/README.md @@ -0,0 +1,36 @@ +--- +title: Getting set up +description: Set up a Flask project and create the Flask app. +--- + +# Getting set up + +Create a virtual environment and activate it. + +``` +python3.10 -m venv .venv +source .venv/bin/activate +``` + +Install Flask. + +``` +pip install flask +``` + +Create a file for the Flask app (I like to call it `app.py`) +Create the Flask app. + +```py title="app.py" +from flask import Flask + +app = Flask(__name__) +``` + +Now you can run this app using the Flask Command-Line Interface (CLI): + +``` +flask run +``` + +But the app doesn't do anything yet! Let's work on our first API endpoint next. \ No newline at end of file diff --git a/docs/docs/03_first_rest_api/03_first_rest_api_endpoint/README.md b/docs/docs/03_first_rest_api/03_first_rest_api_endpoint/README.md new file mode 100644 index 00000000..53ad5a38 --- /dev/null +++ b/docs/docs/03_first_rest_api/03_first_rest_api_endpoint/README.md @@ -0,0 +1,44 @@ +--- +title: Your First REST API Endpoint +description: Learn how to define a REST API endpoint using Flask. +--- + +# Your First REST API Endpoint + +Let's start off by defining where we'll store our data. In most REST APIs, you'd store your data in a database. For now, and for simplicity, we'll store it in a Python list. + +Later on we'll work on making this data dynamic. For now let's use some sample data. + +```py title="app.py" +from flask import Flask + +app = Flask(__name__) + +stores = [{"name": "My Store", "items": [{"name": "my item", "price": 15.99}]}] +``` + +Now that we've got the data stored, we can go ahead and make a Flask route that, when accessed, will return all our data. + +```py title="app.py" +from flask import Flask + +app = Flask(__name__) + +stores = [{"name": "My Store", "items": [{"name": "my item", "price": 15.99}]}] + + +@app.get("/store") +def get_stores(): + return {"stores": stores} +``` + +## Anatomy of a Flask route + +There are two parts to a Flask route: + +- The endpoint decorator +- The function that should run + +The endpoint decorator (`@app.get("/store")`) _registers_ the route's endpoint with Flask. That's the `/store` bit. That way, the Flask app knows that when it receives a request for `/store`, it should run the function. + +The function's job is to do everything that it should, and at the end return _something_. In most REST APIs, we return JSON, but you can return anything that can be represented as text (e.g. XML, HTML, YAML, plain text, or almost anything else). \ No newline at end of file diff --git a/docs/docs/03_first_rest_api/04_what_is_json/README.md b/docs/docs/03_first_rest_api/04_what_is_json/README.md new file mode 100644 index 00000000..cf7a84e7 --- /dev/null +++ b/docs/docs/03_first_rest_api/04_what_is_json/README.md @@ -0,0 +1,71 @@ +--- +title: "What is JSON?" +description: JSON is the way we normally transfer data to and from REST APIs. +--- + +# What is JSON? + +JSON is just a (usually long) string whose contents follow a specific format. + +One example of JSON: + +```json +{ + "key": "value", + "another": 25, + "listic_data": [ + 1, + 3, + 7 + ], + "sub_objects": { + "name": "Rolf", + "age": 25 + } +} +``` + +So at its core, you've got: + +- Strings +- Numbers +- Booleans (`true` or `false`) +- Lists +- Objects (akin to dictionaries in Python) + - Note that objects are not ordered, so the keys could come back in any order. This is not a problem! + +At the top level of a piece of JSON you can have an object or a list. So this is also valid JSON: + +```json +[ + { + "name": "Rolf", + "age": 25 + }, + { + "name": "Anne", + "age": 27 + }, + { + "name": "Adam", + "age": 23 + } +] +``` + +When we return a Python dictionary in a Flask route, Flask automatically turns it into JSON for us, so we don't have to. + +Remember that "turning it into JSON" means two things: + +1. Change Python keywords and values so they match the JSON standard (e.g. `True` to `true`). +2. Turn the whole thing into a single string that our API can return. + +:::tip +Note that JSON can be "prettified" (as the above examples), although usually it is returned by our API "not-prettified": + +```json +[{"name":"Rolf","age":25},{"name":"Anne","age":27},{"name":"Adam","age":23}] +``` + +This removal of newlines and spaces, believe it or not, adds up and can save a lot of bandwidth since there is less data to transfer between the API server and the client. +::: \ No newline at end of file diff --git a/docs/docs/03_first_rest_api/05_make_request_to_rest_api/README.md b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/README.md new file mode 100644 index 00000000..e82d7448 --- /dev/null +++ b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/README.md @@ -0,0 +1,79 @@ +--- +title: How to interact with your REST API +description: Use Postman and Insomnia REST Client to interact with your REST API. +--- + +# How to make a request to a REST API + +One of the most important things about any software development is to make sure that our projects work! + +So we need to be able to test our project, run it, and make sure it does what we think it does. + +There are two main ways of doing this: + +- With automated tests. +- With manual, exploratory testing. + +Usually you'd go with exploratory first, and then you'd make automated tests based on your manual tests. + +In this course we won't cover automated testing of your REST API (it's a long topic, and we've got another course on it). + +However, we will cover a lot of things you can do with manual testing. + +There are two tools I use for exploratory testing: Postman and Insomnia. It's up to you which one to use, but if you haven't used either one before, I recommend Insomnia. + +It's a bit easier to get started with, and it's free and open source. + +Start by [downloading Insomnia REST Client](https://insomnia.rest/). + +Once you've opened it, create a Project. I would call it "REST APIs with Flask and Python". + +![Creating the Project for this course](assets/creating-project.png) + +Then, create a new Request Collection. Call it "Stores REST API". + +![Creating the Stores REST API Request Collection](assets/making-request-collection.png) + +In the Request Collection, we can now add requests! Each request has a few parts: + +- A **method**, such as `GET` or `POST`. The method is just a piece of data sent to the server, but _usually_ certain methods are used for certain things. +- The **URL** that you want to request. For our API, this is formed of the "Base URL" (for Flask apps, that's `http://127.0.0.1:5000`), and the endpoint (e.g. `/store`). +- The **body**, or any data that you want to send in the request. For example, when creating stores or items we might send some data. +- The **headers**, which are other pieces of data with specific names, that the server can use. For example, a header might be sent to help the server understand _who_ is making the request. + +Let's create our first request, `GET /store`. + +Make a new request using the Insomnia interface. First, use the dropdown to start: + +![How to make a request using the Insomnia interface](assets/making-request.png) + +Then enter the request name. Leave the method as `GET`: + +![Enter the request name and method](assets/set-request-name-and-method.png) + +Once you're done, you will see your request in the collection: + +![The request is shown in the collection](assets/before-setting-url.png) + +Next up, enter the URL for your request. Here we will be requesting the `/store` endpoint. Remember to include your Base URL as well: + +![Entering the full URL for the request in Insomnia](assets/url-set.png) + +Once you're done, make sure that your Flask app is running! If it isn't, remember to activate your virtual environment first and then run the app: + +``` +source .venv/bin/activate +flask run +``` + +:::caution +The Flask app will run, by default, on port 5000. If you have another (or the same) app already running, you'll get an error because the port will be "busy". + +If you get an error, read it carefully and make sure that no other Flask app is running on the same port. +::: + +Once your Flask app is running, you can hit "Send" on the Insomnia client, and you should see the JSON come back from your API! + +![Making a request to our API using Insomnia](assets/after-pressing-send.png) + +If that worked and you can see your JSON, you're good to go! You've made your first API request. Now we can continue developing our REST API, remembering to always create new Requests in Insomnia and test our code as we go along! \ No newline at end of file diff --git a/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/after-pressing-send.png b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/after-pressing-send.png new file mode 100644 index 00000000..40ec444d Binary files /dev/null and b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/after-pressing-send.png differ diff --git a/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/before-setting-url.png b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/before-setting-url.png new file mode 100644 index 00000000..778ed6ff Binary files /dev/null and b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/before-setting-url.png differ diff --git a/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/creating-project.png b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/creating-project.png new file mode 100644 index 00000000..ef3dd13d Binary files /dev/null and b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/creating-project.png differ diff --git a/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/making-request-collection.png b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/making-request-collection.png new file mode 100644 index 00000000..802fa372 Binary files /dev/null and b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/making-request-collection.png differ diff --git a/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/making-request.png b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/making-request.png new file mode 100644 index 00000000..1f7da052 Binary files /dev/null and b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/making-request.png differ diff --git a/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/set-request-name-and-method.png b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/set-request-name-and-method.png new file mode 100644 index 00000000..1318a3ab Binary files /dev/null and b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/set-request-name-and-method.png differ diff --git a/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/url-set.png b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/url-set.png new file mode 100644 index 00000000..e57ccb85 Binary files /dev/null and b/docs/docs/03_first_rest_api/05_make_request_to_rest_api/assets/url-set.png differ diff --git a/docs/docs/03_first_rest_api/06_creating_stores/README.md b/docs/docs/03_first_rest_api/06_creating_stores/README.md new file mode 100644 index 00000000..9d2282cd --- /dev/null +++ b/docs/docs/03_first_rest_api/06_creating_stores/README.md @@ -0,0 +1,61 @@ +--- +title: How to create stores +description: Learn how to add data to our REST API. +--- + +# How to create stores in our REST API + +To create a store, we'll receive JSON from our client (in our case, Insomnia, but it could be another Python app, JavaScript, or any other language or tool). + +Our client will send us the name of the store they want to create, and we will add it to the database! + +For this, we will use a `POST` method. `POST` is usually used to receive data from clients and either use it, or create resources with it. + +In order to access the JSON body of a request, we will need to import `request` from `flask`. Your import list should now look like this: + +```py +from flask import Flask, request +``` + +Then, create your endpoint: + +```py title="app.py" +# highlight-start +from flask import Flask, request +# highlight-end + +app = Flask(__name__) + +stores = [{"name": "My Store", "items": [{"name": "my item", "price": 15.99}]}] + + +@app.get("/store") +def get_stores(): + return {"stores": stores} + + +# highlight-start +@app.post("/store") +def create_store(): + request_data = request.get_json() + new_store = {"name": request_data["name"], "items": []} + stores.append(new_store) + return new_store, 201 +# highlight-end +``` + +Here we use `request.get_json()` to retrieve the JSON content of our request. + +Then we create a new dictionary that represents our store. It has a `name` and `items` (which is an empty list). + +Then we append this store to our `stores` list. + +Finally we return the newly created `store`. It's empty, but it serves as a **success message**, to tell our client that we have successfully created what they wanted us to create. + +:::tip Returning a status code +Every response has a status code, which tells the client if the server was successful or not. You already know at least one status code: 404. This means "Not found". + +The most common status code is `200`, which means "OK". That's what Flask returns by default, such as in the `get_stores()` function. + +If we want to return a different status code using Flask, we can put it as the second value returned by an endpoint function. In `create_store()`, we are returning the code `201`, which means "Created". +::: \ No newline at end of file diff --git a/docs/docs/03_first_rest_api/07_creating_items/README.md b/docs/docs/03_first_rest_api/07_creating_items/README.md new file mode 100644 index 00000000..185d1ea3 --- /dev/null +++ b/docs/docs/03_first_rest_api/07_creating_items/README.md @@ -0,0 +1,98 @@ +--- +title: How to create items in each store +description: A brief description of the lecture goes here. +--- + +# How to create items in our REST API + +Next up, let's work on adding items to a store! + +Here's how that's going to work: + +1. The client will send us the store name where they want their new item to go. +2. They will also send us the name and price of the new item. +3. We'll go through the stores one at a time, until we find the correct one (whose name matches what the user gave us). +4. We'll append a new item dictionary to that store's `items`. + +## URL parameters + +There are a few ways for clients to send us data. So far, we've seen that clients can send us JSON. + +But data can be included in a few other places: + +- The body (as JSON, form data, plain text, or a variety of other formats). +- Inside the URL, part of it can be dynamic. +- At the end of the URL, as _query string arguments_. +- In the request headers. + +For this request, the client will send us data in two of these at the same time: the body and the URL. + +How does a dynamic URL look like? + +Here's a couple examples: + +- `/store/My Store/item` +- `/store/another-store/item` +- `/store/a/item` + +In those three URLs, the "store name" was: + +- `My Store` +- `another-store` +- `a` + +We can use Flask to define dynamic endpoints for our routes, and then we can grab the value that the client put inside the URL. + +This allows us to make URLs that make interacting with them more natural. + +For example, it's nicer to make an item by going to `POST /store/My Store/item`, rather than going to `POST /add-item` and then pass in the store name in the JSON body. + +To create a dynamic endpoint for our route, we do this: + +```py +@app.route("/store//item") +``` + +That makes it so the route function will use a `name` parameter whose value will be what the client put in that part of the URL. + +Without further ado, let's make our route for creating items within a store! + +```py title="app.py" +from flask import Flask, request + +app = Flask(__name__) + +stores = [{"name": "My Store", "items": [{"name": "my item", "price": 15.99}]}] + + +@app.get("/store") +def get_stores(): + return {"stores": stores} + + +@app.post("/store") +def create_store(): + request_data = request.get_json() + new_store = {"name": request_data["name"], "items": []} + stores.append(new_store) + return new_store, 201 + + +# highlight-start +@app.post("/store//item") +def create_item(name): + request_data = request.get_json() + for store in stores: + if store["name"] == name: + new_item = {"name": request_data["name"], "price": request_data["price"]} + store["items"].append(new_item) + return new_item + return {"message": "Store not found"}, 404 +# highlight-end +``` + +:::tip Not the most efficient way +In this endpoint we're iterating over all stores in our list until we find the right one. This is very inefficient, but we'll look at better ways to do this kind of thing when we look at databases. + +For now, focus on Flask, and don't worry about efficiency of our code! +::: \ No newline at end of file diff --git a/docs/docs/03_first_rest_api/08_return_data_from_rest_api/README.md b/docs/docs/03_first_rest_api/08_return_data_from_rest_api/README.md new file mode 100644 index 00000000..b22bf944 --- /dev/null +++ b/docs/docs/03_first_rest_api/08_return_data_from_rest_api/README.md @@ -0,0 +1,30 @@ +--- +title: Get a specific store and its items +description: How to use Flask to return data from your REST API to your client. +--- + +# How to get a specific store and its items + +The last thing we want to look at in our first REST API is returning data that uses some filtering. + +Using URL parameters, we can select a specific store: + +```py +@app.get("/store/") +def get_store(name): + for store in stores: + if store["name"] == name: + return store + return {"message": "Store not found"}, 404 +``` + +And just as we did when creating an item in a store, you can use the same endpoint (with a `GET` method), to select the items in a store: + +```py +@app.get("/store//item") +def get_item_in_store(name): + for store in stores: + if store["name"] == name: + return {"items": store["items"]} + return {"message": "Store not found"}, 404 +``` diff --git a/docs/docs/03_first_rest_api/09_final_code/README.md b/docs/docs/03_first_rest_api/09_final_code/README.md new file mode 100644 index 00000000..117a2688 --- /dev/null +++ b/docs/docs/03_first_rest_api/09_final_code/README.md @@ -0,0 +1,65 @@ +--- +title: Final code of this section +description: Overview of the project we've built and all the code in it. +--- + +# Final code of this section + +Here's everything we've written in this section! + +```py title="app.py" +from flask import Flask, request + +app = Flask(__name__) + +stores = [ + { + "name": "My Store", + "items": [ + { + "name": "Chair", + "price": 15.99 + } + ] + } +] + +@app.get("/store") +def get_stores(): + return {"stores": stores} + + +@app.post("/store") +def create_store(): + request_data = request.get_json() + new_store = {"name": request_data["name"], "items": []} + stores.append(new_store) + return new_store, 201 + + +@app.post("/store//item") +def create_item(name): + request_data = request.get_json() + for store in stores: + if store["name"] == name: + new_item = {"name": request_data["name"], "price": request_data["price"]} + store["items"].append(new_item) + return new_item, 201 + return {"message": "Store not found"}, 404 + + +@app.get("/store/") +def get_store(name): + for store in stores: + if store["name"] == name: + return store + return {"message": "Store not found"}, 404 + + +@app.get("/store//item") +def get_item_in_store(name): + for store in stores: + if store["name"] == name: + return {"items": store["items"]} + return {"message": "Store not found"}, 404 +``` \ No newline at end of file diff --git a/docs/docs/03_first_rest_api/_category_.json b/docs/docs/03_first_rest_api/_category_.json new file mode 100644 index 00000000..a409cfe7 --- /dev/null +++ b/docs/docs/03_first_rest_api/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Your First REST API", + "position": 3 +} diff --git a/docs/docs/04_docker_intro/01_what_is_docker_container/README.md b/docs/docs/04_docker_intro/01_what_is_docker_container/README.md new file mode 100644 index 00000000..de6ecb8d --- /dev/null +++ b/docs/docs/04_docker_intro/01_what_is_docker_container/README.md @@ -0,0 +1,88 @@ +# What is a Docker container? + +I'm sure you have heard of the term "Virtual Machine". A virtual machine is an emulation of an Operating System. For example, if you run a Windows virtual machine on your MacOS computer, it will run a whole copy of Windows so you can run Windows programs. + +This diagram shows what happens in that case: + +![Virtual Machine Diagram stack](./assets/vm.drawio.png) + +When you run a Virtual Machine, you can configure what hardware it has access to (e.g. 50% of the host's RAM, 2 CPU cores, etc). + +Docker containers are a bit different because they don't emulate an Operating System. They use the Operating System kernel of your computer, and run as a process within the host. + +Containers have their own storage and networking, but because they don't have to emulate the operating system and everything that entails, they are much more lightweight. + +This diagram shows how Linux containers run in a Linux host: + +![Docker Diagram stack](./assets/docker-linux.drawio.png) + +Looks similar, but the `docker -> container` section is much more efficient than running a VM because it **uses the host's kernel** instead of running its own. + +## What is a kernel? 🍿 + +An Operating System is made up of two main parts: + +- The **kernel** +- Files and programs that come with the operating system + +The Linux kernel, for example, is used by all Linux Operating Systems (like Ubuntu, Fedora, Debian, etc.). + +:::caution +Since containers use the host's kernel, you can't run a Windows Docker container natively in a MacOS host. Similarly, you can't run a Linux container natively on Windows or MacOS hosts. +::: + +## How to run Linux containers on Windows or MacOS? + +When you use Docker Desktop (which I'll show you in the next lecture), it runs a Linux Virtual Machine for you, which then is used to run your Linux containers. + +But aren't you then doing this? + +``` +hardware -> macos -> hypervisor -> linux vm -> docker -> container -> container program +``` + +And isn't that much less efficient than just running the program in a Linux virtual machine? + +Yes. Running Linux containers on MacOS or Windows is "worse" than just running the programs in a Linux VM. However, **99% of the time, you will be running Linux containers in a Linux host, which is much more efficient**. + +:::tip Why do we always run Linux containers in a Linux host? +When you want to deploy your applications to share them with your users, you will almost always be running your app in a Linux server (provided by a _deployment company_, more on that later). There are a few reasons for this. Among them, Linux is free! +::: + +## Why are containers more efficient than VMs? + +From now on let's assume we are running native Linux containers in a Linux host, as that is by far the most common thing to do! + +When you run a VM, it runs the entire operating system. However, when you run a container it uses part of the host's Operating System (called the kernel). Since the kernel is already running anyway, there is much less work for Docker to do. + +As a result, containers start up faster, use fewer resources, and need much less hard disk space to run. + +## Can you run an Ubuntu image when the host is Linux but not Ubuntu? + +Since the Linux kernel is the same between distributions, and since Docker containers only use the host's kernel, it doesn't matter which distribution you are running as a host. You can run containers of any distribution with any other distribution as a host. + +## How many containers can you run at once? + +Each container uses layers to specify what files and programs they need. For example, if you run two containers which both use the same version of Python, you'll actually only need to store that Python executable once. Docker will take care of sharing the data between containers. + +This is why you can run many hundreds of containers in a single host, because there is less duplication of files they use compared to virtual machines. + +## What does a Docker container run? + +If you want to run your Flask app in a Docker container, you need to get (or create) a Docker image that has all the dependencies your Flask app uses, except from the OS kernel: + +- Python +- Dependencies from `requirements.txt` +- Possibly `nginx` or `gunicorn` (more on this when we talk about deployment) + +:::info Aren't there more dependencies? +The keen-eyed among you may be thinking: if all you have is the kernel and nothing else, aren't there more dependencies? + +For example, Python _needs_ the C programming language to run. So shouldn't we need C in our container also? + +Yes! + +When we build our Docker image, we will be building it _on top of_ other, pre-built, existing images. Those images come with the lower-level requirements such as compilers, the C language, and most utilities and programs we need. +::: + +Let's take a look at Docker images in the next lecture. \ No newline at end of file diff --git a/docs/docs/04_docker_intro/01_what_is_docker_container/assets/docker-linux.drawio.png b/docs/docs/04_docker_intro/01_what_is_docker_container/assets/docker-linux.drawio.png new file mode 100644 index 00000000..acdd743d Binary files /dev/null and b/docs/docs/04_docker_intro/01_what_is_docker_container/assets/docker-linux.drawio.png differ diff --git a/docs/docs/04_docker_intro/01_what_is_docker_container/assets/vm.drawio.png b/docs/docs/04_docker_intro/01_what_is_docker_container/assets/vm.drawio.png new file mode 100644 index 00000000..5e5c7adc Binary files /dev/null and b/docs/docs/04_docker_intro/01_what_is_docker_container/assets/vm.drawio.png differ diff --git a/docs/docs/04_docker_intro/01_what_is_docker_container/docker-presentation.key b/docs/docs/04_docker_intro/01_what_is_docker_container/docker-presentation.key new file mode 100755 index 00000000..40b2f472 Binary files /dev/null and b/docs/docs/04_docker_intro/01_what_is_docker_container/docker-presentation.key differ diff --git a/docs/docs/04_docker_intro/02_what_is_docker_image/README.md b/docs/docs/04_docker_intro/02_what_is_docker_image/README.md new file mode 100644 index 00000000..7a78f847 --- /dev/null +++ b/docs/docs/04_docker_intro/02_what_is_docker_image/README.md @@ -0,0 +1,71 @@ +# What is a Docker image? + +A Docker image is a snapshot of source code, libraries, dependencies, tools, and everything else (except the Operating System kernel!) that a container needs to run. + +There are many pre-built images that you can use. For example, some come with Ubuntu (a Linux distribution). Others come with Ubuntu and Python already installed. You can also make your own images that already have Flask installed (as well as other dependencies your app needs). + +:::info Comes with Ubuntu? +In the last lecture I mentioned that Docker containers use the host OS kernel, so why does the container need Ubuntu? + +Remember that operating systems are kernel + programs/libraries. Although the container uses the host kernel, we may still need a lot of programs/libraries that Ubuntu ships with. An example might be a C language compiler! +::: + +This is how you define a Docker image. I'll guide you through how to do this in the next lecture, but bear with me for a second: + +```dockerfile +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] +``` + +This is a `Dockerfile`, a definition of how to create a Docker image. Once you have this file, you can ask Docker to create the Docker image. Then, after creating the Docker image, you can ask Docker to run it as a container. + +``` +Dockerfile ---build--> docker image ---run--> docker container +``` + +In this `Dockerfile` you can see the first line: `FROM python:3.10`. This tells Docker to first download the `python:3.10` image (an image someone else already created), and once that image is created, run the following commands. + +:::info What's in the Python image? +The `python:3.10` image is also built using a `Dockerfile`! You can see the `Dockerfile` for it [here](https://github.com/docker-library/python/blob/master/3.10/buster/Dockerfile). + +You can see it comes `FROM` another image. There is usually a chain of these, images built upon other images, until you reach the base image. In this case, the [base image](https://github.com/docker-library/buildpack-deps/blob/master/debian/buster/Dockerfile) is running Debian (a Linux distribution). + +
+Where is the base image!? +
+
+ +If you really want to go deep, you will be able to find... + +- The [`python3.10:buster`](https://github.com/docker-library/python/blob/master/3.10/buster/Dockerfile) image builds on `buster-scm` +- [`buster-scm`](https://github.com/docker-library/buildpack-deps/blob/master/debian/buster/scm/Dockerfile) builds on `buster-curl` +- [`buster-curl`](https://github.com/docker-library/buildpack-deps/blob/master/debian/buster/curl/Dockerfile) builds on `debian:buster` +- [`debian:buster`](https://github.com/debuerreotype/docker-debian-artifacts/blob/6032f248d825fd35e8b37037b26dc332e4659c64/buster/Dockerfile) looks really weird! + +Eventually, the base image has to physically include the files that make up the operating system. In that last image, that's the Debian OS files that the maintainers have deemed necessary for the `buster` image. + +
+
+
+ +So, why the chain? + +Three main reasons: + +1. So you don't have to write a super long and complex `Dockerfile` which contains everything you need. +2. So pre-published images can be shared online, and all you have to do is download them. +3. So when your own images use the same base image, Docker in your computer only downloads the base image once, saving you a lot of disk space. +::: + +Back to our `Dockerfile`. The commands after `FROM...` are specific to our use case, and do things like install requirements, copy our source code into the image, and tell Docker what command to run when we start a container from this image. + +This separation between images and containers is interesting because once the image is created you can ship it across the internet and: + +- Share it with other developers. +- Deploy it to servers. + +Plus once you've downloaded the image (which can take a while), starting a container from it is almost instant since there's very little work to do. \ No newline at end of file diff --git a/docs/docs/04_docker_intro/03_run_docker_container/README.md b/docs/docs/04_docker_intro/03_run_docker_container/README.md new file mode 100644 index 00000000..23ffa46a --- /dev/null +++ b/docs/docs/04_docker_intro/03_run_docker_container/README.md @@ -0,0 +1,127 @@ +# How to run a Docker container + +## Install Docker Desktop + +Docker Desktop is an application to help you manage your images and containers. Download it and install it here: [https://www.docker.com/products/docker-desktop/](https://www.docker.com/products/docker-desktop/). + +## Create your Docker image + +Next, download the REST API code from Section 3. You can download it here: LINK. + +If you want to use the code you wrote while following the videos, that's fine! Just make sure it works by running the Flask app locally and testing it with Insomnia REST Client or Postman. + +### Write the `Dockerfile` + +In your project folder (i.e. the same folder as `app.py`), we're going to write the Dockerfile. + +To do this, make a file called `Dockerfile`. + +:::caution +Make sure the file is called `Dockerfile`, and not `Dockerfile.txt` or anything like that! +::: + +Inside the `Dockerfile` we're going to write this: + +```dockerfile +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] +``` + +Here's a quick breakdown of what each line does: + +1. `FROM python:3.10` uses the `python:3.10` image as a base. +2. `EXPOSE 5000` is basically documentation[^1]. It tells the user of the Dockerfile that port 5000 is something the running container will use. +3. `WORKDIR /app` does it so everything we do in the Docker image will happen in the image's `/app` directory. +4. `RUN pip install flask` runs a command in the image. Here the command is `pip install flask`, which is what we need to run our app. +5. `COPY . .` is a bit cryptic! It copies everything in the current folder (so `app.py`) into the image's current folder (so `/app`). +6. `CMD ["flask", "run", "--host", "0.0.0.0"]` tells the image what command to run when you start a container. Here the command is `flask run --host=0.0.0.0`. + +:::tip +We need `--host=0.0.0.0` to make Docker be able to do port forwarding, as otherwise the Flask app will only be accessible within the container, but not outside the container. +::: + +Now we need to create the Docker image. + +We do this with the `docker build` command in the terminal. + +:::caution +Make sure to restart your terminal after installing Docker Desktop, so that you have access to the `docker` program in your terminal. +::: + +Open a terminal (in VSCode that's CMD+J or CTRL+J), and run this command: + +``` +docker build -t rest-apis-flask-python . +``` + +The `-t rest-apis-flask-python` flag is optional, but tags the image, giving it a name. It can be handy for later! The final `.` at the end of the command is not a mistake; it tells the command _what_ to build. The `.` means "the current directory". + +This command can take a while to run as it needs to download the `python:3.10` image first. You should see quite a lot of output while the command runs. + +When the command is finished, you should see this (among other things): + +``` + => [2/4] WORKDIR /app 0.4s + => [3/4] RUN pip install flask 2.9s + => [4/4] COPY . . 0.0s + => exporting to image 0.1s + => => exporting layers 0.1s + => => writing image sha256:d9a68a03f868e74bca48567dfc9a0b702d1618941a71b77de12ff14e908ba155 0.0s + => => naming to docker.io/library/rest-apis-first-rest-api 0.0s +``` + +And now your image is built! You should be able to see it in the "Images" section of your Docker Desktop app. + +## Run the Docker container + +When we start a Docker container from this image, it will run the `flask run` command. Remember that by default, `flask run` starts a Flask app using port 5000. + +But the container's ports are not accessible from outside the container by default. We need to tell Docker that when we access a certain port in our computer, those requests and responses should be forwarded to a certain port in our container. + +So we'll run the container, but we must remember to forward a port (e.g. 5000) in our computer to port 5000 in the container + +To do so, run this command: + +``` +docker run -d -p 5000:5000 rest-apis-flask-python +``` + +We're passing a few things to `docker run`: + +1. `-d` runs the container in the background, so that you can close the terminal and the container keeps running. +2. `-p 5000:5000` maps port 5000 in your computer to port 5000 in the container. +3. `rest-apis-flask-python` is the image tag that you want to run. + +You should see something like this as your output: + +``` +9f3c564ac64a1723069dda0e80becb70d3697d4bfcbcb626cd5add0c65df173f +``` + +That's the ID of the container. If you're not using Docker Desktop, you need this ID in order to stop the container later (with `docker rm 9f3c564`, that's the first few characters of the ID). + +And now, if everything has worked, you should be able to access the Flask app _just as if it was running without Docker_! + +:::caution Did something not work? +A common error can happen when the port that you tried to forward isn't available (e.g. because something else is already running): + +``` +docker: Error response from daemon: driver failed programming external connectivity on endpoint bold_goldwasser (ff58b1755c1d1d0fd6b1dd4f59ab3b903b0e68f320624c4a2495672a735039d5): Bind for 0.0.0.0:5000 failed: port is already allocated. +``` + +You have two options: either figure out what is running on port 5000 and shut it down before trying again, or you can change the port that you want to use in your computer: + +``` +docker run -dp 5001:5000 rest-apis-flask-python +``` +::: + +Try making requests using the URL `127.0.0.1:5000` with Insomnia REST Client or Postman, and you should see it working well! + +![Insomnia REST Client successfully made a request to the API running in Docker](assets/running-app-docker.png) + +[^1]: [Docker `EXPOSE` command (Official Documentation)](https://docs.docker.com/engine/reference/builder/#expose) \ No newline at end of file diff --git a/docs/docs/04_docker_intro/03_run_docker_container/assets/running-app-docker.png b/docs/docs/04_docker_intro/03_run_docker_container/assets/running-app-docker.png new file mode 100644 index 00000000..734a638f Binary files /dev/null and b/docs/docs/04_docker_intro/03_run_docker_container/assets/running-app-docker.png differ diff --git a/docs/docs/04_docker_intro/04_in_depth_docker_tutorial/README.md b/docs/docs/04_docker_intro/04_in_depth_docker_tutorial/README.md new file mode 100644 index 00000000..a4e328ed --- /dev/null +++ b/docs/docs/04_docker_intro/04_in_depth_docker_tutorial/README.md @@ -0,0 +1,259 @@ +# In-depth Docker Tutorial + +Like I mentioned earlier on in this section, this course is not a Docker course! + +You can access the official Docker tutorial (which is free and great) by running the tutorial image: + +``` +docker run -dp 80:80 docker/getting-started +``` + +Then you can access http://127.0.0.1/tutorial to launch the official tutorial. + +I recommend going through this (although it uses NodeJS as an example 🤮), as it teaches you quite a few important commands and concepts, such as working with volumes and layers. + +When I went through the official tutorial I took some notes, which you can see below. Remember these may differ from the official tutorial as the Docker team updates the tutorial regularly. + +I hope the notes are helpful as a bit of a cheatsheet, but it doesn't beat going through the tutorial yourself and taking your own notes! + +--- + +## How to write a simple Dockerfile for a Node app + +```dockerfile +FROM node:12-alpine +# Adding build tools to make yarn install work on Apple silicon / arm64 machines +RUN apk add --no-cache python2 g++ make +WORKDIR /app +COPY . . +RUN npm install +CMD ["node", "src/index.js"] +``` + +Then build the image into a new container (the `.` below refers to the current directory, where Docker should find the `Dockerfile`). Optionally tag it: + +``` +docker build -t docker-image-tag . +``` + +## How to run Docker as a daemon (background) + +This prints out the container ID and runs it in the background. + +``` +docker run -d docker-image-tag +``` + +## How to map ports from host machine to Docker container + +This binds port 5000 of the Docker image to port 3000 of the host machine. This way you when you access `127.0.0.1:3000` with your browser, you'll access whatever the Docker image is serving in port `5000`. + +Docs: https://docs.docker.com/engine/reference/commandline/run/#publish-or-expose-port--p---expose + +``` +docker run -p 127.0.0.1:3000:5000 docker-image-tag +``` + +## Working with Docker volumes + +In a Docker volume, the Docker container can store data in the Docker container's filesystem, and it is actually stored in the volume (which is a location in the host machine). + +This is in contrast to a Bind Mount, which is another type of volume where the Docker container reads files (i.e. is provided files to read) from the host machine. The Docker container cannot modify those files when using Bind Mounts. + +| Feature | Named Volumes | Bind Mounts | +| -------------------------------------------- | --------------------------- | ------------------------------- | +| Host Location | Docker chooses | You control | +| Mount Example (using `v`) | `my-volume:/usr/local/data` | `/path/to/data:/usr/local/data` | +| Populates new volume with container contents | Yes | No | +| Supports Volume Drivers | Yes | No | + +### How to map a Named Volume from host to Docker container + +``` +docker run -v volume-name:/path/in/docker/image container-tag +``` + +For example for an app that needs port mapping and a volume: + +``` +docker run -dp 3000:3000 -v todo-db:/etc/todos getting-started +``` + +And to see _where_ in the host machine the data is actually stored: + +``` +docker volume inspect volume-name +``` + +> While running in Docker Desktop, the Docker commands are actually running inside a small VM on your machine. If you wanted to look at the actual contents of the Mountpoint directory, you would need to first get inside of the VM. + +### How to use a Bind Mount to provide your app code to a Docker container + +``` +docker run -dp 3000:3000 \ + -w /app -v "$(pwd):/app" \ + node:12-alpine \ + sh -c "apk add --no-cache python2 g++ make && yarn install && yarn run dev" +``` + +- `-dp 3000:3000` - same as before. Run in detached (background) mode and create a port mapping +- `-w /app` - sets the container's present working directory where the command will run from +- `-v "$(pwd):/app"` - bind mount (link) the host's present `getting-started/app` directory to the container's `/app` directory. Note: Docker requires absolute paths for binding mounts, so in this example we use `pwd` for printing the absolute path of the working directory, i.e. the `app` directory, instead of typing it manually +- `node:12-alpine` - the image to use. Note that this is the base image for our app from the Dockerfile +- `sh -c "yarn install && yarn run dev"` - the command. We're starting a shell using `sh` (alpine doesn't have `bash`) and running `yarn install` to install _all_ dependencies and then running `yarn run dev`. If we look in the `package.json`, we'll see that the `dev` script is starting `nodemon`. + +Note that most of this is identical to the `Dockerfile` that you would create for your project. The only difference is the `-v "$(pwd):/app"` flag. + +## How to pass environment variables to a container + +Use the `-e ENV_NAME=env_value` flag with `docker run`. + +:::caution Secrets in environment variables +Passing secrets like database connection strings or API keys to Docker containers can be done with environment variables, but it isn't the most secure way (the official Docker tutorial [will tell you more](https://diogomonica.com/2017/03/27/why-you-shouldnt-use-env-variables-for-secret-data/)). + +Instead a better option is to use your orchestration framework's secrets management system (that's a mouthful). The two major options are [Kubernetes](https://kubernetes.io/docs/concepts/configuration/secret/) and [Swarm](https://docs.docker.com/engine/swarm/secrets/), and each have their own secrets management system. More info on this later on! +::: + +## Networking between two containers + +First create a network with: + +``` +docker network create network-name +``` + +Then pass the `--network network-name` flag to `docker run`. + +You can also pass `--network-alias` to `docker run` to give the container you are running a DNS name within the network. + +Then create your containers and pass the network to them. For example, this starts up a MySQL image on `linux/amd64`. It also creates a volume and passes in two environment variables which the image uses for configuring MySQL: + +``` +docker run -d \ + --network network-name --network-alias mysql --platform linux/amd64 \ + -v todo-mysql-data:/var/lib/mysql \ + -e MYSQL_ROOT_PASSWORD_FILE=/run/secrets/mysql_root_password \ + -e MYSQL_DATABASE=todos \ + mysql:5.7 +``` + +Then you could run another container on the same network: + +``` +docker run -dp 3000:3000 \ + -w /app -v "$(pwd):/app" \ + --network network-name \ + -e MYSQL_HOST=mysql \ + -e MYSQL_USER=root \ + -e MYSQL_PASSWORD_FILE=/run/secrets/mysql_password \ + -e MYSQL_DB=todos \ + node:12-alpine \ + sh -c "npm install && npm run dev" +``` + +:::caution +In these I'm not passing the MySQL password directly as an environment variable. Instead, I'm passing the path to a file that contains the password. + +That file is created by your Docker orchestration framework's secrets management system. That's a mouthful to say: you define the secret in your orchestration framework, and the framework creates a file which contains the password. That way, the password isn't stored in the environment which is a bit unsafe. + +Your application (or, in this case, MySQL), would have to read the contents of the image to find the password. + +More info on this when we learn about deploying our app in production! +::: + +## How to run multiple containers using Docker Compose + +1. Create a `docker-compose.yml` file in the root of your project. +2. Turn each of the `docker run` commands into a `service` in the `docker-compose.yml` file. +3. This is re-creating the flags passed to the `docker run` command, but in YAML format. + +Example of the two `docker run`s above: + +```yml +services: + app: + image: node:12-alpine + command: sh -c "npm install && npm run dev" + ports: + - 3000:3000 + working_dir: /app + volumes: + - ./:/app + environment: + MYSQL_HOST: mysql + MYSQL_USER: root + MYSQL_PASSWORD_FILE: /run/secrets/mysql_password + MYSQL_DB: todos + mysql: + image: mysql:5.7 + platform: linux/amd64 + volumes: + - todo-mysql-data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password + MYSQL_DATABASE: todos + +volumes: + todo-mysql-data: +``` + +Then, just run `docker compose up -d` and it will start in the background! + +You can see it in Docker desktop. + +Tear it down and remove the containers (but not the volumes) with `docker compose down`. + +## Caching in Dockerfile layers + +Each layer (i.e. each line of text) in a Dockerfile uses caching. + +That means that if Docker doesn't detect that a layer has changed, it won't re-run it. It'll use the last value / files that were generated for the last build. + +However, it also means that if one layer changes and has to be re-built, Docker will re-build all subsequent layers. + +Therefore it's best to set up your Dockerfile so that you can maximise the amount of caching and reduce the chances of a cache bust. + +For example, instead of this: + +``` +FROM node:12-alpine +WORKDIR /app +COPY . . +RUN npm install --production +CMD ["node", "src/index.js"] +``` + +You might do this: + +``` +FROM node:12-alpine +WORKDIR /app +COPY package.json package.lock ./ +RUN npm install --production +COPY . . +CMD ["node", "src/index.js"] +``` + +That way if the `package.json` and `package.lock` files don't change, you won't re-run `npm install`. + +In the first example, if _any_ code files changed, `npm install` would run. Even if it was not needed because the requirements file didn't change. + +### Ignore certain files and folders with `.dockerignore` + +Some files and folders can be safely ignored when copying over to the Docker container. For example, `node_modules` or the Python virtual environment. + +Create a `.dockerignore` file in the root directory of your project (where `docker-compose.yml` lives), and add this (more examples of what to add for a Python project [here](https://github.com/GoogleCloudPlatform/getting-started-python/blob/main/optional-kubernetes-engine/.dockerignore)): + +``` +node_modules +.venv +.env +*.pyc +__pycache__ +``` + +:::danger Secrets in Docker images +Don't include any secrets (like database connection strings or API keys) in your code. For local development you can use a `.env` file, but don't include the `.env` file in your Docker image! + +One of the benefits of Docker images is you can share them with others easily, but that's why you have to be very careful with what you include in them. +::: diff --git a/docs/docs/04_docker_intro/README.md b/docs/docs/04_docker_intro/README.md new file mode 100644 index 00000000..ae8456e5 --- /dev/null +++ b/docs/docs/04_docker_intro/README.md @@ -0,0 +1,18 @@ +# An Introduction to Docker + +:::caution Not a Docker course +An important foreword: this is not a Docker course, and I'm not a Docker expert! + +In this section, and in later sections of this course, I'll teach you what Docker is and how to use it to run and deploy your Flask apps. However, I won't teach you everything there is to know about Docker! +::: + +Docker is a software framework for building, running, and managing **images** and **containers**. + +In order to understand Docker, you need to clarify two questions: + +- What are Docker containers, and how are they different to Virtual Machines? +- What are Docker images? + +After this, you'll be ready to create your own Docker images and use those images to create and run containers. + +Let's take a look at Docker containers in the next lecture! \ No newline at end of file diff --git a/docs/docs/04_docker_intro/_category_.json b/docs/docs/04_docker_intro/_category_.json new file mode 100644 index 00000000..fa8049ed --- /dev/null +++ b/docs/docs/04_docker_intro/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Introduction to Docker", + "position": 4 +} diff --git a/docs/docs/05_flask_smorest/01_why_flask_smorest/README.md b/docs/docs/05_flask_smorest/01_why_flask_smorest/README.md new file mode 100644 index 00000000..6d321b16 --- /dev/null +++ b/docs/docs/05_flask_smorest/01_why_flask_smorest/README.md @@ -0,0 +1,34 @@ +# Why use Flask-Smorest + +There are many different REST API libraries for Flask. In a previous version of this course, we used Flask-RESTful. Now, I recommend using [Flask-Smorest](https://github.com/marshmallow-code/flask-smorest). + +Over the last few months, I've been trialing the major REST libraries for Flask. I've built REST APIs using Flask-RESTful, Flask-RESTX, and Flask-Smorest. + +I was looking to compare the three libraries in a few key areas: + +- **Ease of use and getting started**. Many REST APIs are essentially microservices, so being able to whip one up quickly and without having to go through a steep learning curve is definitely interesting. +- **Maintainability and expandability**. Although many start as microservices, sometimes we have to maintain projects for a long time. And sometimes, they grow past what we originally envisioned. +- **Activity in the library itself**. Even if a library is suitable now, if it is not actively maintained and improved, it may not be suitable in the future. We'd like to teach something that you will use for years to come. +- **Documentation and usage of best practice**. The library should help you write better code by having strong documentation and guiding you into following best practice. If possible, it should use existing, actively maintained libraries as dependencies instead of implementing their own versions of them. +- **Developer experience in production projects**. The main point here was: how easy is it to produce API documentation with the library of choice. Hundreds of students have asked me how to integrate Swagger in their APIs, so it would be great if the library we teach gave it to you out of the box. + +## Flask-Smorest is the most well-rounded + +It ticks all the boxes above: + +- If you want, it can be super similar to Flask-RESTful (which is a compliment, really easy to get started!). +- It uses [marshmallow](https://marshmallow.readthedocs.io/en/stable/) for serialization and deserialization, which is a huge plus. Marshmallow is a very actively-maintained library which is very intuitive and unlocks very easy argument validation. Unfortunately Flask-RESTX [doesn't use marshmallow](https://flask-restx.readthedocs.io/en/latest/marshalling.html), though there are [plans to do so](https://github.com/python-restx/flask-restx/issues/59). +- It provides Swagger (with Swagger UI) and other documentations out of the box. It uses the same marshmallow schemas you use for API validation and some simple decorators in your code to generate the documentation. +- The documentation is the weakest point (compared to Flask-RESTX), but with this course we can help you navigate it. The documentation of marshmallow is superb, so that will also help. + +## If you took an old version of this course... + +Let me tell you about some of the key differences between a project that uses Flask-RESTful and one that uses Flask-Smorest. After reading through these differences, it should be fairly straightforward for you to look at two projects, each using one library, and compare them. + +1. Flask-Smorest uses `flask.views.MethodView` classes registered under a `flask_smorest.Blueprint` instead of `flask_restful.Resource` classes. +2. Flask-Smorest uses `flask_smorest.abort` to return error responses instead of manually returning the error JSON and error code. +3. Flask-Smorest projects define marshmallow schemas that represent incoming data (for deserialization and validation) and outgoing data (for serialization). It uses these schemas to automatically validate the data and turn Python objects into JSON. + +Throughout this section I'll show you how to implement these 3 points in practice, so if you've already got a REST API that uses Flask-RESTful, you'll find it really easy to migrate. + +Of course, you can keep using Flask-RESTful for your existing projects, and only use Flask-Smorest for new projects. That's also an option! Flask-RESTful isn't abandoned or deprecated, so it's still a totally viable option. \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/README.md b/docs/docs/05_flask_smorest/02_data_model_improvements/README.md new file mode 100644 index 00000000..0d3bc5be --- /dev/null +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/README.md @@ -0,0 +1,345 @@ +--- +title: "Data model improvements" +description: "Use dictionaries instead of lists for data storage, and store stores and items separately." +--- + +# Data model improvements + +## Starting code from section 4 + +This is the "First REST API" project from Section 4: + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +
+ + + +```py title="app.py" +from flask import Flask, request + +app = Flask(__name__) + +stores = [ + { + "name": "My Store", + "items": [ + { + "name": "Chair", + "price": 15.99 + } + ] + } +] + +@app.get("/store") # http://127.0.0.1:5000/store +def get_stores(): + return {"stores": stores} + + +@app.post("/store") +def create_store(): + request_data = request.get_json() + new_store = {"name": request_data["name"], "items": []} + stores.append(new_store) + return new_store, 201 + + +@app.post("/store//item") +def create_item(name): + request_data = request.get_json() + for store in stores: + if store["name"] == name: + new_item = {"name": request_data["name"], "price": request_data["price"]} + store["items"].append(new_item) + return new_item, 201 + return {"message": "Store not found"}, 404 + + +@app.get("/store/") +def get_store(name): + for store in stores: + if store["name"] == name: + return store + return {"message": "Store not found"}, 404 + + +@app.get("/store//item") +def get_item_in_store(name): + for store in stores: + if store["name"] == name: + return {"items": store["items"]} + return {"message": "Store not found"}, 404 +``` + + + + +```docker +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] +``` + + + +
+ + +## New files + +Let's start off by creating a `requirements.txt` file with all our dependencies: + +```txt title="requirements.txt" +flask +flask-smorest +python-dotenv +``` + +We're adding `flask-smorest` to help us write REST APIs more easily, and generate documentation for us. + +We're adding `python-dotenv` so it's easier for us to load environment variables and use the `.flaskenv` file. + +Next, let's create the `.flaskenv` file: + +```txt title=".flaskenv" +FLASK_APP=app +FLASK_ENV=development +``` + +If we have the `python-dotenv` library installed, when we run the `flask run` command, Flask will read the variables inside `.flaskenv` and use them to configure the Flask app. + +The configuration that we'll do is to define the Flask app file (here, `app.py`). Then we'll also set the Flask environment to `development`, which does a couple things: + +- Sets debug mode to true, which makes the app give us better error messages +- Sets the app reloading to true, so the app restarts when we make code changes + +We don't want debug mode to be enabled in production (when we deploy our app), but while we're doing development it's definitely a time-saving tool! + +## Code improvements + +### Creating a database file + +First of all, let's move our "database" to another file. + +Create a `db.py` file with the following content: + +```py title="db.py" +stores = {} +items = {} +``` + +In the existing code we only have a `stores` list, so delete that from `app.py`. From now on we will be storing information about items and stores separately. + +:::tip What is in each dictionary? +Each dictionary will closely mimic how a database works: a mapping of ID to data. So each dictionary will be something like this: + +```py +{ + 1: { + "name": "Chair", + "price": 17.99 + }, + 2: { + "name": "Table", + "price": 180.50 + } +} +``` + +This will make it much easier to retrieve a specific store or item, just by knowing its ID. +::: + +Then, import the `stores` and `items` variables from `db.py` in `app.py`: + +```py title="app.py" +from db import stores, items +``` + +## Using stores and items in our API + +Now let's make use of stores and items separately in our API. + +### `get_store` + +Here are the changes we'll need to make: + +
+ + + +```py title="app.py" +@app.get("/store/") +def get_store(name): + for store in stores: + if store["name"] == name: + return store + return {"message": "Store not found"}, 404 +``` + + + + +```py title="app.py" +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + return {"message": "Store not found"}, 404 +``` + +Important to note that in this version, we won't return the items in the store. That's a limitation of our dictionaries-for-database setup that we will solve when we introduce databases! + + + +
+ +### `get_stores` + +
+ + + +```py title="app.py" +@app.get("/store") +def get_stores(): + return {"stores": stores} +``` + + + + +```py title="app.py" +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} +``` + + + +
+ +### `create_store` + +
+ + + +```py title="app.py" +@app.post("/store") +def create_store(): + request_data = request.get_json() + new_store = {"name": request_data["name"], "items": []} + stores.append(new_store) + return new_store, 201 +``` + + + + +```py title="app.py" +@app.post("/store") +def create_store(): + store_data = request.get_json() + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store +``` + + + +
+ +### `create_item` + +
+ + + +```py title="app.py" +@app.post("/store//item") +def create_item(name): + request_data = request.get_json() + for store in stores: + if store["name"] == name: + new_item = {"name": request_data["name"], "price": request_data["price"]} + store["items"].append(new_item) + return new_item, 201 + return {"message": "Store not found"}, 404 +``` + + + + +```py title="app.py" +@app.post("/item") +def create_item(): + item_data = request.get_json() + if item_data["store_id"] not in stores: + return {"message": "Store not found"}, 404 + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item +``` + +Now we are POSTing to `/item` instead of `/store//item`. The endpoint will expect to receive JSON with `price`, `name`, and `store_id`. + + + +
+ + +### `get_items` (new) + +This is not an endpoint we could easily make when we were working with a single `stores` list! + +```py +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} +``` + +### `get_item_in_store` + +
+ + + +```py title="app.py" +@app.get("/store//item") +def get_item_in_store(name): + for store in stores: + if store["name"] == name: + return {"items": store["items"]} + return {"message": "Store not found"}, 404 +``` + + + + +```py title="app.py" +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + return {"message": "Item not found"}, 404 +``` + +Now we are GETting from `/item` instead of `/store//item`. This is because while items are related to stores, they aren't inside a store anymore! + + + +
diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/end/.flaskenv b/docs/docs/05_flask_smorest/02_data_model_improvements/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/end/Dockerfile b/docs/docs/05_flask_smorest/02_data_model_improvements/end/Dockerfile new file mode 100644 index 00000000..93f21d90 --- /dev/null +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/end/Dockerfile @@ -0,0 +1,8 @@ +# In the course we run the app outside Docker +# until lecture 5. +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/end/app.py b/docs/docs/05_flask_smorest/02_data_model_improvements/end/app.py new file mode 100644 index 00000000..5cf4e104 --- /dev/null +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/end/app.py @@ -0,0 +1,58 @@ +import uuid +from flask import Flask, request + +from db import stores, items + + +app = Flask(__name__) + + +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + return {"message": "Item not found"}, 404 + + +@app.post("/item") +def create_item(): + item_data = request.get_json() + if item_data["store_id"] not in stores: + return {"message": "Store not found"}, 404 + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} + + +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + return {"message": "Store not found"}, 404 + + +@app.post("/store") +def create_store(): + store_data = request.get_json() + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/end/db.py b/docs/docs/05_flask_smorest/02_data_model_improvements/end/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/end/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/end/requirements.txt b/docs/docs/05_flask_smorest/02_data_model_improvements/end/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/end/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/start/Dockerfile b/docs/docs/05_flask_smorest/02_data_model_improvements/start/Dockerfile new file mode 100644 index 00000000..4d33d373 --- /dev/null +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/start/app.py b/docs/docs/05_flask_smorest/02_data_model_improvements/start/app.py new file mode 100644 index 00000000..12ca5072 --- /dev/null +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/start/app.py @@ -0,0 +1,45 @@ +from flask import Flask, request + +app = Flask(__name__) + +stores = [{"name": "My Store", "items": [{"name": "Chair", "price": 15.99}]}] + + +@app.get("/store") # http://127.0.0.1:5000/store +def get_stores(): + return {"stores": stores} + + +@app.post("/store") +def create_store(): + request_data = request.get_json() + new_store = {"name": request_data["name"], "items": []} + stores.append(new_store) + return new_store, 201 + + +@app.post("/store//item") +def create_item(name): + request_data = request.get_json() + for store in stores: + if store["name"] == name: + new_item = {"name": request_data["name"], "price": request_data["price"]} + store["items"].append(new_item) + return new_item, 201 + return {"message": "Store not found"}, 404 + + +@app.get("/store/") +def get_store(name): + for store in stores: + if store["name"] == name: + return store + return {"message": "Store not found"}, 404 + + +@app.get("/store//item") +def get_item_in_store(name): + for store in stores: + if store["name"] == name: + return {"items": store["items"]} + return {"message": "Store not found"}, 404 diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/README.md b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/README.md new file mode 100644 index 00000000..406a294d --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/README.md @@ -0,0 +1,121 @@ +--- +title: "Improvements to our first REST API" +description: "Add new error handling and code improvements to the REST API before adding any new endpoints." +--- + +# Improvements to our first REST API + +## Using `flask_smorest.abort` instead of returning errors manually + +At the moment in our API we're doing things like these in case of an error: + +```py title="app.py" +@app.get("/store/") +def get_store(name): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + return {"message": "Store not found"}, 404 +``` + +A small improvement we can do on this is use the `abort` function from Flask-Smorest, which helps us write these messages and include a bit of extra information too. + +Add this import at the top of `app.py`: + +```py title="app.py" +from flask_smorest import abort +``` + +And then let's change our error returns to use `abort`. + +```py title="app.py" +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + # highlight-start + abort(404, message="Store not found.") + # highlight-end +``` + +And here: + +```py title="app.py" +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + # highlight-start + abort(404, message="Item not found.") + # highlight-end +``` + +## Adding error handling on creating items and stores + +At the moment when we create items and stores, we _expect_ there to be certain items in the JSON body of the request. + +If those items are missing, the app will return an error 500, which means "Internal Server Error". + +Instead of that, it's good practice to return an error 400 and a message telling the client what went wrong. + +To do so, let's inspect the body of the request and see if it contains the data we need. + +Let's change our `create_item()` function to this: + +```py title="app.py" +@app.post("/item") +def create_item(): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item +``` + +And our `create_store()` function to this: + +```py title="app.py" +@app.post("/store") +def create_store(): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store +``` \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/.flaskenv b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/Dockerfile b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/Dockerfile new file mode 100644 index 00000000..4d33d373 --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/app.py b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/app.py new file mode 100644 index 00000000..4c8c108e --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/app.py @@ -0,0 +1,84 @@ +import uuid +from flask import Flask, request +from flask_smorest import abort + +from db import stores, items + + +app = Flask(__name__) + + +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + +@app.post("/item") +def create_item(): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} + + +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + +@app.post("/store") +def create_store(): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/db.py b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/requirements.txt b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/.flaskenv b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/Dockerfile b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/Dockerfile new file mode 100644 index 00000000..4d33d373 --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/app.py b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/app.py new file mode 100644 index 00000000..5cf4e104 --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/app.py @@ -0,0 +1,58 @@ +import uuid +from flask import Flask, request + +from db import stores, items + + +app = Flask(__name__) + + +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + return {"message": "Item not found"}, 404 + + +@app.post("/item") +def create_item(): + item_data = request.get_json() + if item_data["store_id"] not in stores: + return {"message": "Store not found"}, 404 + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} + + +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + return {"message": "Store not found"}, 404 + + +@app.post("/store") +def create_store(): + store_data = request.get_json() + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/db.py b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/requirements.txt b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/README.md b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/README.md new file mode 100644 index 00000000..ca8524fd --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/README.md @@ -0,0 +1,74 @@ +--- +title: "New endpoints for our REST API" +description: "Let's add a few routes to our first REST API, so it better matches what a production REST API would look like." +--- + +# New endpoints for our REST API + +## New endpoints + +We want to add some endpoints for added functionality: + +- `DELETE /item/` so we can delete items from the database. +- `PUT /item/` so we can update items. +- `DELETE /store/` so we can delete stores. + +### Deleting items + +This is almost identical to getting items, but we use the `del` keyword to remove the entry from the dictionary. + +```py title="app.py" +@app.delete("/item/") +def delete_item(item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") +``` + +### Updating items + +This is almost identical to creating items, but in this API we've decided to not let item updates change the `store_id` of the item. So clients can change item name and price, but not the store that the item belongs to. + +This is an API design decision, and you could very well allow clients to update the `store_id` if you want! + +```py title="app.py" +@app.put("/item/") +def update_item(item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # You should also prevent keys that aren't 'price' or 'name' to be passed + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") +``` + +:::tip Dictionary update operators +The `|=` syntax is a new dictionary operator. You can read more about it [here](https://blog.teclado.com/python-dictionary-merge-update-operators/). +::: + +### Deleting stores + +This is very similar to deleting items! + +```py title="app.py" +@app.delete("/store/") +def delete_store(store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") +``` diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/assets/build-with-without-volume.png b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/assets/build-with-without-volume.png new file mode 100644 index 00000000..c4941e34 Binary files /dev/null and b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/assets/build-with-without-volume.png differ diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/.flaskenv b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/Dockerfile b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/Dockerfile new file mode 100644 index 00000000..4d33d373 --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/app.py b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/app.py new file mode 100644 index 00000000..1e16d3b0 --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/app.py @@ -0,0 +1,124 @@ +import uuid +from flask import Flask, request +from flask_smorest import abort + +from db import stores, items + + +app = Flask(__name__) + + +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + +@app.post("/item") +def create_item(): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item + + +@app.delete("/item/") +def delete_item(item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + +@app.put("/item/") +def update_item(item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} + + +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + +@app.post("/store") +def create_store(): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store + + +@app.delete("/store/") +def delete_store(store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/db.py b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/requirements.txt b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/.flaskenv b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/Dockerfile b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/Dockerfile new file mode 100644 index 00000000..4d33d373 --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/app.py b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/app.py new file mode 100644 index 00000000..4c8c108e --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/app.py @@ -0,0 +1,84 @@ +import uuid +from flask import Flask, request +from flask_smorest import abort + +from db import stores, items + + +app = Flask(__name__) + + +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + +@app.post("/item") +def create_item(): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} + + +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + +@app.post("/store") +def create_store(): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/db.py b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/requirements.txt b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/README.md b/docs/docs/05_flask_smorest/05_reload_api_docker_container/README.md new file mode 100644 index 00000000..f4dd18b7 --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/README.md @@ -0,0 +1,94 @@ +--- +title: "Reloading API code in Docker container" +description: "Learn how to get your code instantly synced up to the Docker container, so that every time you make a code change it restarts the app in the container and uses the latest code." +--- + +# Reloading API code in Docker container + +## Updating Dockerfile to use `requirements.txt` + +This is the Dockerfile as we've got it: + +```dockerfile +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] +``` + +But there is a problem! It doesn't use the `requirements.txt`, so it only installs Flask as a dependency. + +We want to add `requirements.txt` and install the dependencies from it. You might be tempted to move the `COPY` line above the `RUN` line, and then install it with `pip install -r requirements.txt`. + +But there's a better way! + +```dockerfile +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] +``` + +Here we: + +- Add a new `COPY` line that copies the `requirements.txt` file into the image. This creates a new cached layer, so that if the `requirements.txt` file doesn't change, this line and the following `RUN` line don't run again. +- Change the `pip install` code to use `--no-cache-dir --upgrade`. This makes sure that we don't use any pre-existing pip caches when installing, and also upgrades libraries to the latest version if necessary. + +## Running the container with volumes for hot reloading + +Up to now, we've been re-building the Docker image and re-running the container each time we make a code change. + +This is a bit of a time sink, and a bit annoying to do! Let's do it so that the Docker container runs the code that we're editing. That way, when we make a change to the code, the Flask app should restart and use the new code. + +All we have to do is: + +1. Build the Docker image +2. Run the image, but replace the contents of the image's `/app` directory (where the code is) by the contents of our source code folder in the host machine. + +So, first build the Docker image: + +``` +docker build -t flask-smorest-api . +``` + +Once that's done, the image has an `/app` directory which contains the source code as it was copied from the host machine during the build stage. + +So at this point, we _can_ run a container from this image, and it will run the app _as it was when it was built_: + +``` +docker run -dp 5000:5000 flask-smorest-api +``` + +This should just work, and you can try it out in the Insomnia REST Client to make sure the endpoints all work. + +But like we said earlier, when we make changes to the code we'll have to rebuild and rerun. + +So instead, what we can do is run the image, but replace the image's `/app` directory with the host's source code folder. + +That will cause the source code to change in the Docker container while it's running. And, since we've ran Flask with debug mode on, the Flask app will automatically restart when the code changes. + +To do so, stop the running container (if you have one running), and use this command instead: + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" flask-smorest-api +``` + +- `-dp 5000:5000` - same as before. Run in detached (background) mode and create a port mapping. +- `-w /app` - sets the container's present working directory where the command will run from. +- `-v "$(pwd):/app"` - bind mount (link) the host's present directory to the container's `/app` directory. Note: Docker requires absolute paths for binding mounts, so in this example we use `pwd` for printing the absolute path of the working directory instead of typing it manually. +- `flask-smorest-api` - the image to use. + +And with this, your Docker container now is running the code as shown in your IDE. Plus, since Flask is running with debug mode on, the Flask app will restart when you make code changes! + +:::info +Using this kind of volume mapping only makes sense _during development_. When you share your Docker image or deploy it, you won't be sharing anything from the host to the container. That's why it's still important to include the original source code in the image when you build it. +::: + +Just to recap, here are the two ways we've seen to run your Docker container: + +![Diagram showing two ways of running a Docker container from a built image, with and without volume mapping](./assets/build-with-without-volume.png) \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/assets/build-with-without-volume.png b/docs/docs/05_flask_smorest/05_reload_api_docker_container/assets/build-with-without-volume.png new file mode 100644 index 00000000..c4941e34 Binary files /dev/null and b/docs/docs/05_flask_smorest/05_reload_api_docker_container/assets/build-with-without-volume.png differ diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/.flaskenv b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/Dockerfile b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/app.py b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/app.py new file mode 100644 index 00000000..1e16d3b0 --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/app.py @@ -0,0 +1,124 @@ +import uuid +from flask import Flask, request +from flask_smorest import abort + +from db import stores, items + + +app = Flask(__name__) + + +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + +@app.post("/item") +def create_item(): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item + + +@app.delete("/item/") +def delete_item(item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + +@app.put("/item/") +def update_item(item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} + + +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + +@app.post("/store") +def create_store(): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store + + +@app.delete("/store/") +def delete_store(store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/db.py b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/requirements.txt b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/.flaskenv b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/Dockerfile b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/Dockerfile new file mode 100644 index 00000000..4d33d373 --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/app.py b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/app.py new file mode 100644 index 00000000..1e16d3b0 --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/app.py @@ -0,0 +1,124 @@ +import uuid +from flask import Flask, request +from flask_smorest import abort + +from db import stores, items + + +app = Flask(__name__) + + +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + +@app.post("/item") +def create_item(): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item + + +@app.delete("/item/") +def delete_item(item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + +@app.put("/item/") +def update_item(item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} + + +@app.get("/store/") +def get_store(store_id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + +@app.post("/store") +def create_store(): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store + + +@app.delete("/store/") +def delete_store(store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/db.py b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/requirements.txt b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/README.md b/docs/docs/05_flask_smorest/06_api_with_method_views/README.md new file mode 100644 index 00000000..c18134e8 --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/README.md @@ -0,0 +1,216 @@ +--- +title: How to use Blueprints and MethodViews +description: Flask-Smorest MethodViews allow us to simplify API Resources by defining all methods that interact with the resource in one Python class. +--- + +# How to use Flask-Smorest MethodViews and Blueprints + +Let's improve the structure of our code by splitting items and stores endpoints into their own files. + +Let's create a `resources` folder, and inside it create `item.py` and `store.py`. + +## Creating a blueprint for each related group of resources + +### `resources/store.py` + +Let's start in `store.py`, and create a `Blueprint`: + +```py title="resources/store.py" +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores + + +blp = Blueprint("stores", __name__, description="Operations on stores") +``` + +The `Blueprint` arguments are the same as the Flask `Blueprint`[^1], with an added optional `description` keyword argument: + +1. `"stores"` is the name of the blueprint. This will be shown in the documentation and is prepended to the endpoint names when you use `url_for` (we won't use it). +2. `__name__` is the "import name". +3. The `description` will be shown in the documentation UI. + + +Now that we've got this, let's add our `MethodView`s. These are classes where each method maps to one endpoint. The interesting thing is that method names are important: + +```py title="resources/store.py" +@blp.route("/store/") +class Store(MethodView): + def get(self, store_id): + pass + + def delete(self, store_id): + pass +``` + +Two things are going on here: + +1. The endpoint is associated to the `MethodView` class. Here, the class is called `Store` and the endpoint is `/store/`. +2. There are two methods inside the `Store` class: `get` and `delete`. These are going to map directly to `GET /store/` and `DELETE /store/`. + +Now we can copy the code from earlier into each of the methods: + +```py title="resources/store.py" +@blp.route("/store/") +class Store(MethodView): + def get(self, store_id): + try: + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(self, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") +``` + +Now, still inside the same file, we can add another `MethodView` with a different endpoint, for the `/store` route: + +```py title="resources/store.py" +@blp.route("/store") +class StoreList(MethodView): + def get(self): + return {"stores": list(stores.values())} + + def post(self): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store +``` + +### `resources/item.py` + +Let's do the same thing with the `resources/item.py` file: + +```py title="resources/item.py" +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + def put(self, item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + def get(self): + return {"items": list(items.values())} + + def post(self): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item +``` + +## Import blueprints and Flask-Smorest configuration + +Finally, we have to import the `Blueprints` inside `app.py`, and register them with Flask-Smorest: + +```py title="app.py" +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) +``` + +I've also added a few config variables to the `app.config`. The `PROPAGATE_EXCEPTIONS` value is used so that when an exception is raised in an extension, it is bubbled up to the main Flask app so you'd see it more easily. + +The other config values are there for the documentation of our API, and they define things such as the API name and version, as well as information for the Swagger UI. + +Now you should be able to go to `http://127.0.0.1:5000/swagger-ui` and see your Swagger documentation rendered out! + +[^1]: [Flask Blueprint (Flask Official Documentation)](https://flask.palletsprojects.com/en/2.1.x/api/#flask.Blueprint) \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/end/.flaskenv b/docs/docs/05_flask_smorest/06_api_with_method_views/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/end/Dockerfile b/docs/docs/05_flask_smorest/06_api_with_method_views/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/end/app.py b/docs/docs/05_flask_smorest/06_api_with_method_views/end/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/end/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/end/db.py b/docs/docs/05_flask_smorest/06_api_with_method_views/end/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/end/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/end/requirements.txt b/docs/docs/05_flask_smorest/06_api_with_method_views/end/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/end/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/section11/models/__init__.py b/docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/__init__.py similarity index 100% rename from section11/models/__init__.py rename to docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/__init__.py diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/item.py b/docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/item.py new file mode 100644 index 00000000..743afb86 --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/item.py @@ -0,0 +1,77 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + def put(self, item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + def get(self): + return {"items": list(items.values())} + + def post(self): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/store.py b/docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/store.py new file mode 100644 index 00000000..23ae7c63 --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/store.py @@ -0,0 +1,49 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + def get(self, store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(self, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + def get(self): + return {"stores": list(stores.values())} + + def post(self): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/start/.flaskenv b/docs/docs/05_flask_smorest/06_api_with_method_views/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/start/Dockerfile b/docs/docs/05_flask_smorest/06_api_with_method_views/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/start/app.py b/docs/docs/05_flask_smorest/06_api_with_method_views/start/app.py new file mode 100644 index 00000000..5609044d --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/start/app.py @@ -0,0 +1,124 @@ +import uuid +from flask import Flask, request +from flask_smorest import abort + +from db import stores, items + + +app = Flask(__name__) + + +@app.get("/item/") +def get_item(item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + +@app.delete("/item/") +def delete_item(item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + +@app.put("/item/") +def update_item(item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.values())} + + +@app.post("/item") +def create_item(): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item + + +@app.get("/store/") +def get_store(store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + +@app.delete("/store/") +def delete_store(store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.values())} + + +@app.post("/store") +def create_store(): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/start/db.py b/docs/docs/05_flask_smorest/06_api_with_method_views/start/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/start/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/start/requirements.txt b/docs/docs/05_flask_smorest/06_api_with_method_views/start/requirements.txt new file mode 100644 index 00000000..5a4a2b93 --- /dev/null +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/start/requirements.txt @@ -0,0 +1,2 @@ +flask +python-dotenv \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/README.md b/docs/docs/05_flask_smorest/07_marshmallow_schemas/README.md new file mode 100644 index 00000000..5a802257 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/README.md @@ -0,0 +1,80 @@ +--- +title: Adding marshmallow schemas +description: A marshmallow schema is useful for validation and serialization. Learn how to write them in this lecture. +--- + +# Adding marshmallow schemas + +Something that we're lacking in our API at the moment is validation. We've done a _tiny_ bit of it with this kind of code: + +```py +if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data +): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) +``` + +But there's so much more we can do. For starters, some data points may be optional in some endpoints. We also want to check the data type is correct (i.e. `price` shouldn't be a string, for example). + +To do this kind of checking we can construct a massive `if` statement, or we can use a library that is made specifically for it. + +The `marshmallow`[^1] library is used to define _what_ data fields we want, and then we can pass incoming data through the validator. We can also go the other way round, and give it a Python object which `marshmallow` then turns into a dictionary. + +## Writing the `ItemSchema` + +Here's the definition of an `Item` using `marshmallow` (this is called a **schema**): + +```py title="schemas.py" +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) +``` + +A couple of weird things maybe! + +The `id` field is a string, but it has the `dump_only=True` argument. This means that when we use marshmallow to _validate incoming data_, the `id` field won't be used or expected. However, when we use marshmallow to _serialize_ data to be returned to a client, the `id` field will be included in the output. + +The other fields will be used for both validation and serialization, and since they have the `required=True` argument, that means that when we do validation if the fields are not present, an error will be raised. + +`marshmallow` will also check the data type with `fields.Float` and `fields.Int`. + +## Writing the `ItemUpdateSchema` + +Something that even to do this day sits a bit weird with me is having multiple different schemas for different applications. + +When we want to update an Item, we have different requirements than when we want to create an item. + +The main difference is that the incoming data to our API when we update an item is different than when we create one. Fields are optional, such that not all item fields should be required. Also, you may not want to allow certain fields _at all_. + +This is the `ItemUpdateSchema`: + +```py title="schemas.py" +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() +``` + +As you can see, these are not `required=True`. I've also taken off the `id` and `store_id` fields, because: + +- This schema will only be used for incoming data, and we will never receive an `id`. +- We don't want clients to be able to change the `store_id` of an item. If you wanted to allow this, you can add the `store_id` field here as well. + +## Writing the `StoreSchema` + +```py title="schemas.py" +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) +``` + +There's not much to explain here! Similar to the `ItemSchema`, we have `id` and `name` since those are the only fields we need for a store. \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/.flaskenv b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/Dockerfile b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/app.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/db.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/requirements.txt b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/section11/resources/__init__.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/__init__.py similarity index 100% rename from section11/resources/__init__.py rename to docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/__init__.py diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/item.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/item.py new file mode 100644 index 00000000..743afb86 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/item.py @@ -0,0 +1,77 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + def put(self, item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + def get(self): + return {"items": list(items.values())} + + def post(self): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/store.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/store.py new file mode 100644 index 00000000..008b4d2c --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/store.py @@ -0,0 +1,49 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + def get(cls, store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(cls, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + def get(cls): + return {"stores": list(stores.values())} + + def post(cls): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/schemas.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/schemas.py new file mode 100644 index 00000000..0a4ff8d4 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/schemas.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/.flaskenv b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/Dockerfile b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/app.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/db.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/requirements.txt b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/requirements.txt new file mode 100644 index 00000000..bb14f3ed --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-smorest +python-dotenv \ No newline at end of file diff --git a/section6/models/__init__.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/__init__.py similarity index 100% rename from section6/models/__init__.py rename to docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/__init__.py diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/item.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/item.py new file mode 100644 index 00000000..743afb86 --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/item.py @@ -0,0 +1,77 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + def put(self, item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + def get(self): + return {"items": list(items.values())} + + def post(self): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/store.py b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/store.py new file mode 100644 index 00000000..008b4d2c --- /dev/null +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/store.py @@ -0,0 +1,49 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + def get(cls, store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(cls, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + def get(cls): + return {"stores": list(stores.values())} + + def post(cls): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/README.md b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/README.md new file mode 100644 index 00000000..ff7f0da6 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/README.md @@ -0,0 +1,129 @@ +--- +title: Validation with marshmallow +description: We can use the marshmallow library to validate request data from our API clients. +--- + +# Validation with marshmallow + +Now that we've got our schemas written, let's use them to validate incoming data to our API. + +With Flask-Smorest, this couldn't be easier! + +Let's start with `resources/item.py` + +## Validation in `resources/item.py` + +At the top of the file, import the schemas: + +```py +from schemas import ItemSchema, ItemUpdateSchema +``` + +We have two sets of data that may be incoming (in the JSON body of a request): new items and updating items. + +So let's go to the `ItemList#post` method and make a couple changes! + +First, let's get rid of the existing data validation. Delete the highlighted lines below: + +```py +def post(self): + # highlight-start + item_data = request.get_json() + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + # highlight-end + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item +``` + +Now, I know what you're thinking! What about `item_data`? Do we not need to keep that? + +When we use `marshmallow` for validation with Flask-Smorest, it will inject the validated data into our method for us. + +Look at these two highlighted lines: + +```py +# highlight-start +@blp.arguments(ItemSchema) +def post(self, item_data): + # highlight-end + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item +``` + +Nice! + +Plus, doing this also adds to your Swagger UI documentation. + +Let's do the same when updating items: + +```py +# highlight-start +@blp.arguments(ItemUpdateSchema) +def put(self, item_data, item_id): + # highlight-end + try: + item = items[item_id] + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") +``` + +:::caution Order of parameters +Be careful here since we've now got `item_data` and `item_id`. The URL arguments come in at the end. The injected arguments are passed first, so `item_data` goes before `item_id` in our function signature. +::: + +## Validation in `resources/store.py` + +Now let's do the same in `store.py`! + +At the top of the file, import the schema: + +```py +from schemas import StoreSchema +``` + +When creating a store, we'll have this: + +```py +@blp.arguments(StoreSchema) +def post(cls, store_data): + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store +``` \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/.flaskenv b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/Dockerfile b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/app.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/db.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/requirements.txt b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/section6/resources/__init__.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/__init__.py similarity index 100% rename from section6/resources/__init__.py rename to docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/__init__.py diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/item.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/item.py new file mode 100644 index 00000000..d3b09ede --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/item.py @@ -0,0 +1,57 @@ +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from schemas import ItemSchema, ItemUpdateSchema +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + @blp.arguments(ItemUpdateSchema) + def put(self, item_data, item_id): + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + def get(self): + return {"items": list(items.values())} + + @blp.arguments(ItemSchema) + def post(self, item_data): + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/store.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/store.py new file mode 100644 index 00000000..bc8824b8 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/store.py @@ -0,0 +1,44 @@ +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + def get(cls, store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(cls, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + def get(cls): + return {"stores": list(stores.values())} + + @blp.arguments(StoreSchema) + def post(cls, store_data): + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/schemas.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/schemas.py new file mode 100644 index 00000000..0a4ff8d4 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/schemas.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/.flaskenv b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/Dockerfile b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/app.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/db.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/requirements.txt b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/__init__.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/__init__.py @@ -0,0 +1 @@ + diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/item.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/item.py new file mode 100644 index 00000000..743afb86 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/item.py @@ -0,0 +1,77 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + def put(self, item_id): + item_data = request.get_json() + # There's more validation to do here! + # Like making sure price is a number, and also both items are optional + # Difficult to do with an if statement... + if "price" not in item_data or "name" not in item_data: + abort( + 400, + message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", + ) + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + def get(self): + return {"items": list(items.values())} + + def post(self): + item_data = request.get_json() + # Here not only we need to validate data exists, + # But also what type of data. Price should be a float, + # for example. + if ( + "price" not in item_data + or "store_id" not in item_data + or "name" not in item_data + ): + abort( + 400, + message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", + ) + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/store.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/store.py new file mode 100644 index 00000000..008b4d2c --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/store.py @@ -0,0 +1,49 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + def get(cls, store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(cls, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + def get(cls): + return {"stores": list(stores.values())} + + def post(cls): + store_data = request.get_json() + if "name" not in store_data: + abort( + 400, + message="Bad request. Ensure 'name' is included in the JSON payload.", + ) + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/schemas.py b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/schemas.py new file mode 100644 index 00000000..0a4ff8d4 --- /dev/null +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/schemas.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/README.md b/docs/docs/05_flask_smorest/09_decorating_responses/README.md new file mode 100644 index 00000000..7126b50f --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/README.md @@ -0,0 +1,169 @@ +--- +title: Decorating responses with Flask-Smorest +description: Add response serialization and status code to API endpoints, and add to your documentation in the process. +--- + +# Decorating responses with Flask-Smorest + +We can use marshmallow schemas for serialization when we respond to a client. To do so, we need to tell Flask-Smorest what Schema to use when responding. + +This will do a few things: + +1. Update your documentation to show what data and status code will be returned by the endpoint. +2. Pass any data your endpoint returns through the marshmallow schema, casting data types and removing data that isn't in the schema. + +## Decorating responses in `resources/item.py` + +Let's start with retrieving a specific item. + +Up until now, we've been doing this: + +```py +def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") +``` + +But now we can run the `items[item_id]` dictionary through the marshmallow schema and tell Flask-Smorest about it so the documentation will be updated: + +```py +@blp.response(200, ItemSchema) +def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") +``` + +:::info +The number, `200`, is the status code. It means "OK" (all good). +::: + +Our endpoint for updating items looks like this: + +```py +@blp.arguments(ItemUpdateSchema) +def put(self, item_data, item_id): + try: + item = items[item_id] + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") +``` + +Let's pass this through the schema as well: + +```py +@blp.arguments(ItemUpdateSchema) +# highlight-start +@blp.response(200, ItemSchema) +# highlight-end +def put(self, item_data, item_id): + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") +``` + +:::caution +Careful with the order of decorators in these functions! +::: + +When we get to returning a list of items, it looks like this: + +```py +# highlight-start +@blp.response(200, ItemSchema(many=True)) +# highlight-end +def get(self): + return items.values() +``` + +And finally, don't forget to decorate the new item endpoint too: + +```py +@blp.arguments(ItemSchema) +# highlight-start +@blp.response(201, ItemSchema) +# highlight-end +def post(self, item_data): + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item +``` + +## Decorating responses in `resources/store.py` + +Going a bit more quickly here since you already know what's going on with this decorator. The highlighted lines are new: + +```py title="resources/store.py" +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + # highlight-start + @blp.response(200, StoreSchema) + # highlight-end + def get(cls, store_id): + try: + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(cls, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + # highlight-start + @blp.response(200, StoreSchema(many=True)) + # highlight-end + def get(cls): + return stores.values() + + @blp.arguments(StoreSchema) + # highlight-start + @blp.response(201, StoreSchema) + # highlight-end + def post(cls, store_data): + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store +``` \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/.flaskenv b/docs/docs/05_flask_smorest/09_decorating_responses/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/Dockerfile b/docs/docs/05_flask_smorest/09_decorating_responses/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/app.py b/docs/docs/05_flask_smorest/09_decorating_responses/end/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/db.py b/docs/docs/05_flask_smorest/09_decorating_responses/end/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/requirements.txt b/docs/docs/05_flask_smorest/09_decorating_responses/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/__init__.py b/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/__init__.py @@ -0,0 +1 @@ + diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/item.py b/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/item.py new file mode 100644 index 00000000..eab1fdaa --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/item.py @@ -0,0 +1,61 @@ +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from schemas import ItemSchema, ItemUpdateSchema +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return items.values() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/store.py b/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/store.py new file mode 100644 index 00000000..1bfa7cbf --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/resources/store.py @@ -0,0 +1,47 @@ +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(cls, store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(cls, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(cls): + return stores.values() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(cls, store_data): + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/schemas.py b/docs/docs/05_flask_smorest/09_decorating_responses/end/schemas.py new file mode 100644 index 00000000..0a4ff8d4 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/schemas.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/.flaskenv b/docs/docs/05_flask_smorest/09_decorating_responses/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/Dockerfile b/docs/docs/05_flask_smorest/09_decorating_responses/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/app.py b/docs/docs/05_flask_smorest/09_decorating_responses/start/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/db.py b/docs/docs/05_flask_smorest/09_decorating_responses/start/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/requirements.txt b/docs/docs/05_flask_smorest/09_decorating_responses/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/__init__.py b/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/__init__.py @@ -0,0 +1 @@ + diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/item.py b/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/item.py new file mode 100644 index 00000000..d3b09ede --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/item.py @@ -0,0 +1,57 @@ +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from schemas import ItemSchema, ItemUpdateSchema +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + @blp.arguments(ItemUpdateSchema) + def put(self, item_data, item_id): + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + def get(self): + return {"items": list(items.values())} + + @blp.arguments(ItemSchema) + def post(self, item_data): + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/store.py b/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/store.py new file mode 100644 index 00000000..bc8824b8 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/resources/store.py @@ -0,0 +1,44 @@ +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + def get(cls, store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(cls, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + def get(cls): + return {"stores": list(stores.values())} + + @blp.arguments(StoreSchema) + def post(cls, store_data): + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/schemas.py b/docs/docs/05_flask_smorest/09_decorating_responses/start/schemas.py new file mode 100644 index 00000000..0a4ff8d4 --- /dev/null +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/schemas.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) diff --git a/docs/docs/05_flask_smorest/_category_.json b/docs/docs/05_flask_smorest/_category_.json new file mode 100644 index 00000000..fb2ffeb7 --- /dev/null +++ b/docs/docs/05_flask_smorest/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Flask-Smorest for More Efficient Development", + "position": 5 +} diff --git a/docs/docs/06_sql_storage_sqlalchemy/01_project_overview_sqlalchemy/README.md b/docs/docs/06_sql_storage_sqlalchemy/01_project_overview_sqlalchemy/README.md new file mode 100644 index 00000000..94c15cec --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/01_project_overview_sqlalchemy/README.md @@ -0,0 +1,47 @@ +--- +title: Project Overview, and why use SQLAlchemy +description: Let's look at what we'll do in this section. There are no changes to the client-facing API at all, just changes internally to how we store data. +--- + +# Project Overview (and why use SQLAlchemy) + +In this section we'll make absolutely no changes to the API! However, we will completely change the way we store data. + +Up until now, we've been storing data in an "in-memory database": a couple of Python dictionaries. When we stop the app, the data is destroyed. This is obviously not great, so we want to move to a proper store that can keep data around between app restarts! + +We'll be using a relational database for data storage, and there are many different options: SQLite, MySQL, PostgreSQL, and others. + +At this point we have two options regarding how to interact with the database: + +1. We can write SQL code and execute it ourselves. For example, when we want to add an item to the database we'd write something like `INSERT INTO items (name, price, store_id) VALUES ("Chair", 17.99, 1)`. +2. We can use an ORM, which can take Python objects and turn them into database rows. + +For this project, we are going to use an ORM because it makes the code much cleaner and simpler. Also, the ORM library (SQLAlchemy) helps us with many potential issues with using SQL, such as: + +- Multi-threading support +- Handling creating the tables and defining the rows +- Database migrations (with help of another library, Alembic) +- Like mentioned, it makes the code cleaner, simpler, and shorter + +To get started, add the following to the `requirements.txt` file: + +```text title="requirements.txt" +sqlalchemy +flask-sqlalchemy +``` + +
+ What is Flask-SQLAlchemy? +
+

SQLAlchemy is the ORM library, that helps map Python classes to database tables and columns, and turns Python objects of those classes into specific rows.

+

Flask-SQLAlchemy is a Flask extension which helps connect SQLAlchemy to Flask apps.

+
+
+ +With this, install your requirements (remember to activate your virtual environment first!). + +``` +pip install -r requirements.txt +``` + +Let's begin creating our SQLAlchemy models in the next lecture. \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/README.md b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/README.md new file mode 100644 index 00000000..3631c38a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/README.md @@ -0,0 +1,44 @@ +--- +title: Create a simple SQLAlchemy Model +description: Lecture description goes here. +--- + +# Create a simple SQLAlchemy Model + +## Initialize the SQLAlchemy instance + +```python title="db.py" +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() +``` + +## Create models without relationships + +Every model inherits from `db.Model`. That way when we tell SQLAlchemy about them (in [Configure Flask-SQLAlchemy](../configure_flask_sqlalchemy))), it will know to look at them to create tables. + +Every model also has a few properties that let us interact with the database through the model, such as `query` (more on this in [Insert models in the database with SQLAlchemy](../insert_models_sqlalchemy)). + +```python title="models/item.py" +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, unique=False, nullable=False) +``` + +```python title="models/store.py" +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) +``` \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/section11/db.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/db.py similarity index 100% rename from section11/db.py rename to docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/db.py diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/item.py new file mode 100644 index 00000000..65b647a3 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/item.py @@ -0,0 +1,10 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, unique=False, nullable=False) diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/store.py new file mode 100644 index 00000000..49fc39dd --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/store.py @@ -0,0 +1,8 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/item.py new file mode 100644 index 00000000..2295ab7e --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/item.py @@ -0,0 +1,36 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + raise NotImplementedError("Creating an item is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/store.py new file mode 100644 index 00000000..2784516b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/store.py @@ -0,0 +1,32 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + raise NotImplementedError("Creating a store is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/schemas.py new file mode 100644 index 00000000..0a4ff8d4 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/schemas.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/section6/db.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/db.py similarity index 100% rename from section6/db.py rename to docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/db.py diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/item.py new file mode 100644 index 00000000..2295ab7e --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/item.py @@ -0,0 +1,36 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + raise NotImplementedError("Creating an item is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/store.py new file mode 100644 index 00000000..2784516b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/store.py @@ -0,0 +1,32 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + raise NotImplementedError("Creating a store is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/schemas.py new file mode 100644 index 00000000..0a4ff8d4 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/schemas.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/README.md b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/README.md new file mode 100644 index 00000000..8a93f5a3 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/README.md @@ -0,0 +1,114 @@ +--- +title: One-to-many relationships with SQLAlchemy +description: Model relationships let us easily retrieve information about a related model, without having to do SQL JOINs manually. +--- + +# One-to-many relationships with SQLAlchemy + +```python title="models/item.py" +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + # highlight-start + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + # highlight-end +``` + +```python title="models/store.py" +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + # highlight-start + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + # highlight-end +``` + +To make it easier to import and use the models, I'll also create a `models/__init__.py` file that imports the models from their files: + +```python title="models/__init__.py" +from models.store import StoreModel +from models.item import ItemModel +``` + +## What is `lazy="dynamic"`? + +Without `lazy="dynamic"`, the `items` attribute of the `StoreModel` resolves to a list of `ItemModel` objects. + +With `lazy="dynamic"`, the `items` attribute resolves to a SQLAlchemy **query**, which has some benefits and drawbacks: + +- A key benefit is load speed. Because SQLAlchemy doesn't have to go to the `items` table and load items, stores will load faster. +- A key drawback is accessing the `items` of a store isn't as easy. + - However this has another hidden benefit, which is that when you _do_ load items, you can do things like filtering before loading. + +Here's how you could get all the items, giving you a list of `ItemModel` objects. Assume `store` is a `StoreModel` instance: + +```python +store.items.all() +``` + +And here's how you would do some filtering: + +```python +store.items.filter_by(name=="Chair").first() +``` + +## Updating our marshmallow schemas + +Now that the models have these relationships, we can modify our marshmallow schemas so they will return some or all of the information about the related models. + +We do this with the `Nested` marshmallow field. + +:::caution +Something to be careful about is having schema A which has a nested schema B, which has a nested schema A. + +This will lead to an infinite nesting, which is obviously never what you want! +::: + +To avoid infinite nesting, we are renaming our schemas which _don't_ use nested fields to `Plain`, such as `PlainItemSchema` and `PlainStoreSchema`. + +Then the schemas that _do_ use nesting can be called `ItemSchema` and `StoreSchema`, and they inherit from the plain schemas. This reduces duplication and prevents infinite nesting. + +```python title="schemas.py" +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) +``` \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/db.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/item.py new file mode 100644 index 00000000..2295ab7e --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/item.py @@ -0,0 +1,36 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + raise NotImplementedError("Creating an item is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/store.py new file mode 100644 index 00000000..2784516b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/store.py @@ -0,0 +1,32 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + raise NotImplementedError("Creating a store is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/db.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/item.py new file mode 100644 index 00000000..65b647a3 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/item.py @@ -0,0 +1,10 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, unique=False, nullable=False) diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/store.py new file mode 100644 index 00000000..49fc39dd --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/store.py @@ -0,0 +1,8 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/item.py new file mode 100644 index 00000000..2295ab7e --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/item.py @@ -0,0 +1,36 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + raise NotImplementedError("Creating an item is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/store.py new file mode 100644 index 00000000..2784516b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/store.py @@ -0,0 +1,32 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + raise NotImplementedError("Creating a store is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/README.md b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/README.md new file mode 100644 index 00000000..d71c9c75 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/README.md @@ -0,0 +1,111 @@ +--- +title: Configure Flask-SQLAlchemy +description: Link Flask-SQLAlchemy with our Flask app and create the initial tables. +--- + +# Configure Flask-SQLAlchemy + +We want to add two imports to `app.py`: + +```python title="app.py" +from db import db + +import models +``` + +## The Flask app factory pattern + +Up until now, we've been creating the `app` variable (which is the Flask app) directly in `app.py`. + +With the app factory pattern, we write a function that _returns_ `app`. That way we can _pass configuration values_ to the function, so that we configure the app before getting it back. + +This is especially useful for testing, but also if you want to do things like have staging and production apps. + +To do the app factory, all we do is place all the app-creation code inside a function which **must be called `create_app()`**. + +```python title="app.py" +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + +# highlight-start +def create_app(): + app = Flask(__name__) + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + api = Api(app) + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app +# highlight-end +``` + +## Add Flask-SQLAlchemy code to the app factory + +```python title="app.py" +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + +# highlight-start +def create_app(db_url=None): + # highlight-end + app = Flask(__name__) + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + # highlight-start + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + # highlight-end + api = Api(app) + + # highlight-start + @app.before_first_request + def create_tables(): + db.create_all() + # highlight-end + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app +``` + +We've done three things: + +1. Added the `db_url` parameter. This lets us create an app with a certain database URL, or alternatively try to fetch the database URL from the environment variables. The default value will be a local SQLite file, if we don't pass a value ourselves and it isn't in the environment. +2. Added two SQLAlchemy values to `app.config`. One is the database URL (or URI), the other is a [configuration option](https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/) which improves performance. +3. Registered a function to run before our Flask app handles its first request. The function will tell SQLAlchemy to use what it knows in order to create all the database tables we need. + +:::tip How does SQLAlchemy know what tables to create? +The line `import models` lets SQLAlchemy know what models exist in our application. Because they are `db.Model` instances, SQLAlchemy will look at their `__tablename__` and defined `db.Column` attributes to create the tables. +::: \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/db.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/item.py new file mode 100644 index 00000000..2295ab7e --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/item.py @@ -0,0 +1,36 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + raise NotImplementedError("Creating an item is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/store.py new file mode 100644 index 00000000..2784516b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/store.py @@ -0,0 +1,32 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + raise NotImplementedError("Creating a store is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/db.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/item.py new file mode 100644 index 00000000..2295ab7e --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/item.py @@ -0,0 +1,36 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + raise NotImplementedError("Creating an item is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/store.py new file mode 100644 index 00000000..2784516b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/store.py @@ -0,0 +1,32 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + raise NotImplementedError("Creating a store is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/README.md b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/README.md new file mode 100644 index 00000000..5de2b86b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/README.md @@ -0,0 +1,64 @@ +--- +title: Insert models in the database with SQLAlchemy +description: Learn how to use SQLAlchemy to add new rows to our SQL database. +--- + +# Insert models in the database with SQLAlchemy + +Inserting models with SQLAlchemy couldn't be easier! We'll use the `db.session`[^1] variable to `.add()` a model. Let's begin working on our `Item` resource: + +```python title="resources/item.py" +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel + +... + +@blp.arguments(ItemSchema) +@blp.response(201, ItemSchema) +def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item +``` + +Similarly in our `Store` resource: + +```python title="resources/store.py" +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel + +... + +@blp.arguments(StoreSchema) +@blp.response(201, StoreSchema) +def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store +``` + +Note here we're catching two different errors, `IntegrityError` for when a client attempts to create a store with a name that already exists, and `SQLAlchemyError` for anything else. + +Since the `StoreModel`'s `name` column is marked as `unique=True`, then an `IntegrityError` is raised when we try to insert another row with the same name. + +[^1]: [Session Basics (SQLAlchemy Documentation)](https://docs.sqlalchemy.org/en/14/orm/session_basics.html) \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/db.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/item.py new file mode 100644 index 00000000..af5b5843 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/item.py @@ -0,0 +1,44 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/store.py new file mode 100644 index 00000000..514680fb --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/store.py @@ -0,0 +1,44 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/db.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/item.py new file mode 100644 index 00000000..2295ab7e --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/item.py @@ -0,0 +1,36 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + raise NotImplementedError("Creating an item is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/store.py new file mode 100644 index 00000000..2784516b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/store.py @@ -0,0 +1,32 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + raise NotImplementedError("Creating a store is not implemented.") diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/README.md b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/README.md new file mode 100644 index 00000000..0fd1a4b5 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/README.md @@ -0,0 +1,71 @@ +--- +title: Get models by ID from the database +description: Learn how to fetch a specific model using its primary key column, and how to return a 404 page if it isn't found. +--- + +# Get models by ID from the database using SQLAlchemy + +Using the model class's `query` attribute, we have access to two very handy methods: + +- `ItemModel.query.get(item_id)` gives us an `ItemModel` object from the database where the `item_id` matches the primary key. +- `ItemModel.query.get_or_404(item_id)` does the same, but makes Flask immediately return a "Not Found" message, together with a 404 error code, if no model can be found with that ID in the database. + +:::tip +When we use `.get_or_404()` and nothing is found, this is the response from the API: + +```json +{"code": 404, "status": "Not Found"} +``` + +The status code of this response is also 404. +::: + +We're going to use `.get_or_404()` repeatedly in our resources! + +For now, and since we'll need an `ItemModel` instance in all our `Item` resource methods, let's add that: + +```python title="resources/item.py" +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + # highlight-start + item = ItemModel.query.get_or_404(item_id) + return item + # highlight-end + + def delete(self, item_id): + # highlight-start + item = ItemModel.query.get_or_404(item_id) + # highlight-end + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + # highlight-start + item = ItemModel.query.get_or_404(item_id) + # highlight-end + raise NotImplementedError("Updating an item is not implemented.") +``` + +Similarly in our `Store` resource: + +```python title="resources/store.py" +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + # highlight-start + store = StoreModel.query.get_or_404(store_id) + return store + # highlight-end + + def delete(self, store_id): + # highlight-start + store = StoreModel.query.get_or_404(store_id) + # highlight-end + raise NotImplementedError("Deleting a store is not implemented.") +``` + +With this, we're ready to continue! \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/db.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/item.py new file mode 100644 index 00000000..0880bd3c --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/item.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/store.py new file mode 100644 index 00000000..132c4bd2 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/store.py @@ -0,0 +1,46 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/db.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/item.py new file mode 100644 index 00000000..af5b5843 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/item.py @@ -0,0 +1,44 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + raise NotImplementedError("Getting an item is not implemented.") + + def delete(self, item_id): + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/store.py new file mode 100644 index 00000000..514680fb --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/store.py @@ -0,0 +1,44 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + raise NotImplementedError("Getting a store is not implemented.") + + def delete(self, store_id): + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/README.md b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/README.md new file mode 100644 index 00000000..ec017440 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/README.md @@ -0,0 +1,31 @@ +--- +title: Updating models with SQLAlchemy +description: How to make changes to an existing model, or insert one if it doesn't already exist. +--- + +# Updating models with SQLAlchemy + +A frequent operation in REST APIs is the "upsert", or "update or insert". + +This is an idempotent operation where we send the data we want the API to store. If the data identifier already exists, an update is done. If it doesn't, it is created. + +This idempotency is frequently seen with `PUT` requests. You can see it in action here: + +```python title="resources/item.py" +@blp.arguments(ItemUpdateSchema) +@blp.response(200, ItemSchema) +def put(self, item_data, item_id): + # highlight-start + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + # highlight-end +``` diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/db.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/item.py new file mode 100644 index 00000000..e6fedffb --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/item.py @@ -0,0 +1,57 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/store.py new file mode 100644 index 00000000..132c4bd2 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/store.py @@ -0,0 +1,46 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/db.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/item.py new file mode 100644 index 00000000..0880bd3c --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/item.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + raise NotImplementedError("Updating an item is not implemented.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/store.py new file mode 100644 index 00000000..132c4bd2 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/store.py @@ -0,0 +1,46 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/README.md b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/README.md new file mode 100644 index 00000000..c75e88be --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/README.md @@ -0,0 +1,24 @@ +--- +title: Retrieve a list of all models +description: Get more than one model and return it as a list from the API. +--- + +# Retrieve a list of all models + +Using the `query` attribute of our model class, we can retrieve all the results of the query: + +```python title="resources/item.py" +@blp.response(200, ItemSchema(many=True)) +def get(self): + # highlight-start + return ItemModel.query.all() + # highlight-end +``` + +```python title="resources/store.py" +@blp.response(200, StoreSchema(many=True)) +def get(self): + # highlight-start + return StoreModel.query.all() + # highlight-end +``` \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/db.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/item.py new file mode 100644 index 00000000..71b6f62c --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/item.py @@ -0,0 +1,57 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/store.py new file mode 100644 index 00000000..f2d77a29 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/store.py @@ -0,0 +1,46 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/db.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/item.py new file mode 100644 index 00000000..e6fedffb --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/item.py @@ -0,0 +1,57 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing items is not implemented.") + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/store.py new file mode 100644 index 00000000..132c4bd2 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/store.py @@ -0,0 +1,46 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + raise NotImplementedError("Listing stores is not implemented.") + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/README.md b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/README.md new file mode 100644 index 00000000..c9bc06ed --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/README.md @@ -0,0 +1,28 @@ +--- +title: Delete models with SQLAlchemy +description: Use SQLAlchemy to handle removal of a specific model. +--- + +# Delete models with SQLAlchemy + +Just as with adding, deleting models is a matter of using `db.session`, and then committing when the deletion is complete: + +```python title="resources/item.py" +def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + # highlight-start + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + # highlight-end +``` + +```python title="resources/store.py" +def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + # highlight-start + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + # highlight-end +``` \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/db.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/Dockerfile b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/db.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/item.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/store.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/requirements.txt b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/__init__.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/item.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/item.py new file mode 100644 index 00000000..71b6f62c --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/item.py @@ -0,0 +1,57 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + raise NotImplementedError("Deleting an item is not implemented.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/store.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/store.py new file mode 100644 index 00000000..f2d77a29 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/store.py @@ -0,0 +1,46 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + raise NotImplementedError("Deleting a store is not implemented.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/06_sql_storage_sqlalchemy/10_conclusion/README.md b/docs/docs/06_sql_storage_sqlalchemy/10_conclusion/README.md new file mode 100644 index 00000000..5d7b98d3 --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/10_conclusion/README.md @@ -0,0 +1,104 @@ +--- +title: Conclusion of this section +description: Review everything we've changed this section to add SQL storage with SQLAlchemy to our API. +--- + +# Conclusion of this section + +Adding SQL storage to our app has required quite a few changes! Let's do a quick review. + +## Installed SQLAlchemy and Flask-SQLAlchemy + +``` +pip install sqlalchemy flask-sqlalchemy +``` + +And + +```text title="requirements.txt" +sqlalchemy +flask-sqlalchemy +``` + +## Created models + +```python title="models/item.py" +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") +``` + +And + +```python title="models/store.py" +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") +``` + +## Updated resources to use SQLAlchemy + +Previously we were using Python dictionaries as a database. Now we've swapped them out for using SQLAlchemy models by: + +- Importing the models in our resource files +- Retrieving models from the database with `ModelClass.query.get_or_404(model_id)`. +- Updating models by changing attributes, or creating new model class instances, and then saving and committing with `db.session.add(model_instance)` and `db.session.commit()`. +- Deleting models with `db.session.delete(model_instance)` followed by `db.session.commit()`. + +## Updated marshmallow schemas + +Since now our models have relationships, that means that the schemas can have `Nested` fields. + +The schemas that don't have `Nested` fields we've called "Plain" schemas, and those that do are named after the model they represent. + +```python title="schemas.py" +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) +``` + +And that's it! Quite a few changes, but hopefully you're still with me. + +In the following sections we'll be adding more functionality to our API, so stay tuned! \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/_category_.json b/docs/docs/06_sql_storage_sqlalchemy/_category_.json new file mode 100644 index 00000000..fd8d269b --- /dev/null +++ b/docs/docs/06_sql_storage_sqlalchemy/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "SQL Storage with Flask-SQLAlchemy", + "position": 6 +} diff --git a/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/README.md b/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/README.md new file mode 100644 index 00000000..813f9e0f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/README.md @@ -0,0 +1,43 @@ +--- +title: Changes in this section +description: In this section we add Tags to our Stores, and link these to Items using a many-to-many relationship. +--- + +# Changes in this section + +It's common for online stores to use "tags" to group items and to be able to search for them a bit more easily. + +For example, an item "Chair" could be tagged with "Furniture" and "Office". + +Another item, "Laptop", could be tagged with "Tech" and "Office". + +So one item can be associated with many tags, and one tag can be associated with many items. + +This is a many-to-many relationship, which is bit trickier to implement than the one-to-many we've already implemented between Items and Stores. + +## When you have many stores + +We want to add one more constraint to tags, however. That is that if we have many stores, it's possible each store wants to use different tags. So the tags we create will be unique to each store. + +This means that tags will have: + +- A many-to-one relationship with stores +- A many-to-many relationship with items + +Here's a diagram to illustrate what this looks like: + +![ER database model showing relationships](./assets/db_model.drawio.png) + +## New API endpoints to be added + +In this section we will add all the Tag endpoints: + + +| Method | Endpoint | Description | +| -------- | --------------------- | ------------------------------------------------------- | +| `GET` | `/store/{id}/tag` | Get a list of tags in a store. | +| `POST` | `/store/{id}/tag` | Create a new tag. | +| `POST` | `/item/{id}/tag/{id}` | Link an item in a store with a tag from the same store. | +| `DELETE` | `/item/{id}/tag/{id}` | Unlink a tag from an item. | +| `GET` | `/tag/{id}` | Get information about a tag given its unique id. | +| `DELETE` | `/tag/{id}` | Delete a tag, which must have no associated items. | \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/assets/db_model.drawio.png b/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/assets/db_model.drawio.png new file mode 100644 index 00000000..a156618d Binary files /dev/null and b/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/assets/db_model.drawio.png differ diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md new file mode 100644 index 00000000..9ca03bb6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md @@ -0,0 +1,191 @@ +--- +title: One-to-many relationships review +description: A super-quick look at creating the Tag model and setting up the one-to-many relationship with Stores. +--- + +# One-to-many relationship between Tag and Store + +Since we've already learned how to set up one-to-many relationships with SQLAlchemy when we looked at Items and Stores, let's go quickly in this section. + +## The SQLAlchemy models + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +
+ + + +```python title="models/tag.py" +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") +``` + + + + +```python title="models/store.py" +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + # highlight-start + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + # highlight-end +``` + + + +
+ +Remember to import the `TagModel` in `models/__init__.py` so that it is then imported by `app.py`. Otherwise SQLAlchemy won't know about it, and it won't be able to create the tables. + +## The marshmallow schemas + +These are the new schemas we'll add. Note that none of the tag schemas have any notion of "items". We'll add those to the schemas when we construct the many-to-many relationship. + +In the `StoreSchema` we add a new list field for the nested `PlainTagSchema`, just as it has with `PlainItemSchema`. + +```python title="schemas.py" +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + # highlight-start + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + # highlight-end + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) +``` + +## The API endpoints + +Let's add the Tag endpoints that aren't related to Items: + + +| Method | Endpoint | Description | +| ---------- | --------------------- | ------------------------------------------------------- | +| ✅ `GET` | `/store/{id}/tag` | Get a list of tags in a store. | +| ✅ `POST` | `/store/{id}/tag` | Create a new tag. | +| ❌ `POST` | `/item/{id}/tag/{id}` | Link an item in a store with a tag from the same store. | +| ❌ `DELETE` | `/item/{id}/tag/{id}` | Unlink a tag from an item. | +| ✅ `GET` | `/tag/{id}` | Get information about a tag given its unique id. | +| ❌ `DELETE` | `/tag/{id}` | Delete a tag, which must have no associated items. | + +Here's the code we need to write to add these endpoints: + +```python title="resources/tag.py" +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel +from schemas import TagSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag +``` + +## Register the Tag blueprint in `app.py` + +Finally, we need to remember to import the blueprint and register it! + +```python title="app.py" +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +# highlight-start +from resources.tag import blp as TagBlueprint +# highlight-end + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + # highlight-start + api.register_blueprint(TagBlueprint) + # highlight-end + + return app +``` \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/Dockerfile b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py new file mode 100644 index 00000000..8d1cee05 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/db.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/item.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/store.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/tag.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/tag.py new file mode 100644 index 00000000..c84ee7cb --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/tag.py @@ -0,0 +1,11 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/requirements.txt b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/item.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/store.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/tag.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/tag.py new file mode 100644 index 00000000..593d6e4a --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/tag.py @@ -0,0 +1,45 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel +from schemas import TagSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/schemas.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/schemas.py new file mode 100644 index 00000000..a1083164 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/schemas.py @@ -0,0 +1,37 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/Dockerfile b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py new file mode 100644 index 00000000..0af1f37f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py @@ -0,0 +1,34 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/db.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/item.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/store.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/requirements.txt b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/item.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/store.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/schemas.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md new file mode 100644 index 00000000..2b2dcab2 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md @@ -0,0 +1,278 @@ +--- +title: Many-to-many relationships +description: Learn to set up a many-to-many relationship between two models using SQLAlchemy. +--- + +# Many-to-many relationships + +## The SQLAlchemy models + +In one-to-many relationships, one of the models has a foreign key that links it to another model. + +However, for a many-to-many relationship, one model can't have a single value as a foreign key (otherwise it would be a one-to-many!). Instead, what we do is construct a **secondary table** that has, in each row, a tag ID and and item ID. + +| id | tag_id | item_id | +| --- | ------ | ------- | +| 1 | 2 | 5 | +| 2 | 1 | 4 | +| 3 | 4 | 5 | +| 4 | 1 | 3 | + +
+ Explanation of the table above +
+

The table above has 4 rows, which tell us the following:

+
    +
  1. Tag with ID 1 is linked to Items with IDs 3 and 4.
  2. +
  3. Tag with ID 2 is linked to Item with ID 5.
  4. +
  5. Tag with ID 4 is linked to Item with ID 5.
  6. +
+

And therefore:

+
    +
  1. Item with ID 3 is linked to Tag with ID 1.
  2. +
  3. Item with ID 4 is linked to Tag with ID 1.
  4. +
  5. Item with ID 5 is linked to Tags with IDs 2 and 4.
  6. +
+

This is how many-to-many relationships work, and through this secondary table, the Tag.items and Item.tags attributes will be populated by SQLAlchemy.

+
+
+ +The rows in this table then signify a link between a specific tag and a specific item, but without the need for those values to be stored in the tag or item models themselves. + +### Writing the secondary table for many-to-many relationships + +As we've just seen, many-to-many relationships use a secondary table which stores which models of one side are related to which models of the other side. + +Just as we did with `Item`, `Store`, and `Tag`, we'll create a model for this secondary table: + +```python title="models/item_tags.py" +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) +``` + +Let's also add this to our `models/__init__.py` file: + +```python title="models/__init__.py" +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags +``` + +### Using the secondary table in the main models + + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +
+ + + +```python title="models/tag.py" +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + # highlight-start + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") + # highlight-end +``` + + + + +```python title="models/item.py" +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + # highlight-start + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") + # highlight-end +``` + + + +
+ +## The marshmallow schemas + +Next up, let's add the nested fields to the marshmallow schemas. + +The `TagAndItemSchema` will be used to return information about both the Item and Tag that have been modified in an endpoint, together with an informative message. + +```python title="schemas.py" +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + # highlight-start + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + # highlight-end + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + # highlight-start + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + # highlight-end + store = fields.Nested(PlainStoreSchema(), dump_only=True) + +# highlight-start +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) +# highlight-end +``` + +## The API endpoints + +Now let's add the rest of our API endpoints (grayed out are the ones we implemented in [one-to-many relationships review](../one_to_many_review/))! + +| Method | Endpoint | Description | +| ---------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| ✅ `GET` | `/store/{id}/tag` | Get a list of tags in a store. | +| ✅ `POST` | `/store/{id}/tag` | Create a new tag. | +| ✅ `POST` | `/item/{id}/tag/{id}` | Link an item in a store with a tag from the same store. | +| ✅ `DELETE` | `/item/{id}/tag/{id}` | Unlink a tag from an item. | +| ✅ `GET` | `/tag/{id}` | Get information about a tag given its unique id. | +| ✅ `DELETE` | `/tag/{id}` | Delete a tag, which must have no associated items. | + +Here's the code (new lines highlighted): + +```python title="resources/tag.py" +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +# highlight-start +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema +# highlight-end + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + +# highlight-start +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} +# highlight-end + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + # highlight-start + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) + # highlight-end +``` + +And with that, we're done! + +Now we're ready to look at securing API endpoints with user authentication. \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/Dockerfile b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py new file mode 100644 index 00000000..8d1cee05 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/conftest.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/db.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item_tags.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/store.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/tag.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/requirements.txt b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/item.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/store.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/tag.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/schemas.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/schemas.py new file mode 100644 index 00000000..99b9c94a --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/schemas.py @@ -0,0 +1,45 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/Dockerfile b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py new file mode 100644 index 00000000..8d1cee05 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/db.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/item.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/store.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/tag.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/tag.py new file mode 100644 index 00000000..c84ee7cb --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/tag.py @@ -0,0 +1,11 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/requirements.txt b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/item.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/store.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/tag.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/tag.py new file mode 100644 index 00000000..593d6e4a --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/tag.py @@ -0,0 +1,45 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel +from schemas import TagSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/schemas.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/schemas.py new file mode 100644 index 00000000..a1083164 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/schemas.py @@ -0,0 +1,37 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) diff --git a/docs/docs/07_sqlalchemy_many_to_many/_category_.json b/docs/docs/07_sqlalchemy_many_to_many/_category_.json new file mode 100644 index 00000000..c21b5700 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Many-to-many relationships with SQLAlchemy", + "position": 7 +} diff --git a/docs/docs/08_flask_jwt_extended/01_section_changes/README.md b/docs/docs/08_flask_jwt_extended/01_section_changes/README.md new file mode 100644 index 00000000..e83323d4 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/01_section_changes/README.md @@ -0,0 +1,19 @@ +--- +title: Changes in this section +description: Overview of the API endpoints we'll use for user registration and authentication. +--- + +# Changes in this section + +In this section we will add the following endpoints: + +| Method | Endpoint | Description | +| -------------- | ----------------- | ----------------------------------------------------- | +| `POST` | `/register` | Create user accounts given an `email` and `password`. | +| `POST` | `/login` | Get a JWT given an `email` and `password`. | +| 🔒
`POST` | `/logout` | Revoke a JWT. | +| 🔒
`POST` | `/refresh` | Get a fresh JWT given a refresh JWT. | +| `GET` | `/user/{user_id}` | (dev-only) Get info about a user given their ID. | +| `DELETE` | `/user/{user_id}` | (dev-only) Delete a user given their ID. | + +We will also protect some existing endpoints by requiring a JWT from clients. You can see which endpoints will be protected in [The API we'll build in this course](/docs/course_intro/what_is_rest_api/#the-api-well-build-in-this-course) \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/02_what_is_a_jwt/README.md b/docs/docs/08_flask_jwt_extended/02_what_is_a_jwt/README.md new file mode 100644 index 00000000..2abb0173 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/02_what_is_a_jwt/README.md @@ -0,0 +1,21 @@ +--- +title: What is a JWT? +description: Understand what a JWT is, what data it contains, and how it may be used. +--- + +# What is a JWT? + +A JWT is a signed JSON object with a specific structure. Our Flask app will sign the JWTs with the secret key, proving that _it generated them_. + +The Flask app generates a JWT when a user logs in (with their username and password). In the JWT, we'll store the user ID. The client then stores the JWT and sends it to us on every request. + +Because we can prove our app generated the JWT (through its signature), and we will receive the JWT with the user ID in every request, we can _treat requests that include a JWT as "logged in"_. + +For example, if we want certain endpoints to only be accessible to logged-in users, all we do is require a JWT in them. Since the client can only get a JWT after logging in, we know that including a JWT is proof that the client logged in successfully at some point in the past. + +And since the JWT includes the user ID inside it, when we receive a JWT we know _who logged in_ to get the JWT. + +There's a lot more information about JWTs here: [https://jwt.io/introduction](https://jwt.io/introduction). This includes information such as: + +- What is stored inside a JWT? +- Are JWTs secure? diff --git a/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/README.md b/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/README.md new file mode 100644 index 00000000..94eea4d3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/README.md @@ -0,0 +1,72 @@ +--- +title: How are JWTs used? +description: Learn who uses JWTs and how they are used by clients and servers to perform authentication. +--- + +# How are JWTs used? + +:::info JWT vs. Access token? +An "access token" is any piece of information that a client can use to authenticate. In this API, we use JWTs. Therefore you can say that the JWT and the access token are one and the same! +::: + +We've learned that a JWT is generated by the API and sent to the client. When the client wants to login they will send the API information that allows them to do so: usually, the user's username and password. The API then validates that this login information is correct, and generates the access token. + +Inside the access token, the API stores identifying information for the user. Then the access token is sent to the client who stores it in whichever way they see fit. In every subsequent request to the API, the client should include the access token. That way, just with that information, the API can tell _who_ made the request. The API can decode the access token and see inside it the identifying information for the user for whom the access token was generated. + +Here is a diagram of the interaction between client and API to generate an access token: + +
+ +![Diagram showing the flow between client and server to generate an access token](./assets/access-token-flow.drawio.png) + +
+ +## An example of using access tokens + +For example, let's say you want to make an API that has an endpoint `/my-info`. This endpoint should send the client information about the currently logged-in user. + +Let's imagine that **the client** is a website. In the website, there is a button, "See my info", which when clicked sends a request to the API's `/my-info` endpoint to get the logged-in user's information. + +### Clicking the button without logging in + +If the user navigates to the website and clicks the "See my info" button, the website will send a request to the API. Because the user hasn't logged in yet, the website doesn't have an access token generated for this user. + +Therefore, the API responds with an "authentication error". + +The website receives the authentication error and that tells it that the user hasn't logged in. So the website can show the user a log-in form, for the user to enter their username and password. + +When the user enters their username and password, the website will send a request to the API's `/login` endpoint. The API then responds with the access token. The website stores the access token for use later. + +If the user clicks the "See my info" button again, now the website will include the access token in the request. + +The server will then: + +1. See the access token. +2. Decode it. +3. Look at what user the access token was generated for. +4. Load _that_ user's information from the database. +5. Respond with that user's information. + +The website receives the user's information, and can display it. + +This is why the user sees their own information, and not someone else's. The access token was generated after they logged in with their details, and the access token stores their user ID. The server will use that to retrieve the correct data. + +Here is a rather long diagram depicting what happens: + +
+ +![Diagram showing flow of data when user wants to load their information but aren't logged in](./assets/my-info-flow.drawio.png) + +
+ +:::warning This course deals only with the API +Remember that in this course, we're making the API. We are not concerned with the client! We don't care how the client stores the access token or even whether the client is a website, mobile app, Postman or Insomnia, or anything else! +::: + +## When do users provide their username and password? + +Access tokens don't last forever: they normally have expiry times within 30 days of being generated. The shorter the expiry time of an access token, the more often that the user has to re-authenticate by providing their username and password, but the more secure the token is. + +Tokens are more secure if they expire sooner because if the user forgets to log out of a shared device, and someone else tries to use their account, the token will expire and they will be unable to use the account. + +Obviously, it's not a great experience for users if they have to keep re-entering their username and password constantly. Towards the end of this section we will learn about [token refreshing](../12_token_refreshing_flask_jwt_extended/README.md), which is a way to reduce the amount of times users have to re-authenticate, without affecting security too much. diff --git a/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/assets/access-token-flow.drawio.png b/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/assets/access-token-flow.drawio.png new file mode 100644 index 00000000..8a2409ab Binary files /dev/null and b/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/assets/access-token-flow.drawio.png differ diff --git a/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/assets/my-info-flow.drawio.png b/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/assets/my-info-flow.drawio.png new file mode 100644 index 00000000..b986ac4e Binary files /dev/null and b/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/assets/my-info-flow.drawio.png differ diff --git a/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/how-are-jwts-used.key b/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/how-are-jwts-used.key new file mode 100755 index 00000000..cc0af56d Binary files /dev/null and b/docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/how-are-jwts-used.key differ diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/README.md b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/README.md new file mode 100644 index 00000000..30bb9851 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/README.md @@ -0,0 +1,71 @@ +--- +title: Flask-JWT-Extended setup +description: Install and set up the Flask-JWT-Extended extension with our REST API. +--- + +# Flask-JWT-Extended setup + +First, let's update our requirements: + +```diff title="requirements.txt" ++ flask-jwt-extended +``` + +Then we must do two things: + +- Add the extension to our `app.py`. +- Set a secret key that the extension will use to _sign_ the JWTs. + +```python title="app.py" +from flask import Flask +from flask_smorest import Api +# highlight-start +from flask_jwt_extended import JWTManager +# highlight-end + +from db import db + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + # highlight-start + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + # highlight-end + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app +``` + +:::caution +The secret key set here, `"jose"`, is **not very safe**. + +Instead you should generate a long and random secret key using something like `secrets.SystemRandom().getrandbits(128)`. +::: \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.dockerignore b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flake8 b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flaskenv b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/Dockerfile b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/app.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/app.py new file mode 100644 index 00000000..c22daf8c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/app.py @@ -0,0 +1,41 @@ +from flask import Flask +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/conftest.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/db.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/__init__.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/item.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/item_tags.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/store.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/tag.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/requirements.txt b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__init__.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/item.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/store.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/tag.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/schemas.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/schemas.py new file mode 100644 index 00000000..99b9c94a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/schemas.py @@ -0,0 +1,45 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.dockerignore b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flake8 b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flaskenv b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/Dockerfile b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/app.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/app.py new file mode 100644 index 00000000..de81eb75 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/app.py @@ -0,0 +1,37 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/conftest.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/db.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/__init__.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/item.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/item_tags.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/store.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/tag.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/requirements.txt b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__init__.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/item.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/store.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/tag.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/schemas.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/schemas.py new file mode 100644 index 00000000..99b9c94a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/schemas.py @@ -0,0 +1,45 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/README.md b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/README.md new file mode 100644 index 00000000..1608fdc3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/README.md @@ -0,0 +1,44 @@ +--- +title: The User model and schema +description: Create the SQLAlchemy User model and marshmallow schema. +--- + +# The User model and schema + +Just as we did with items, stores, and tags, let's create two classes for our users: + +- The SQLAlchemy model, to interact with the database. +- The marshmallow schema, to deserialize data from clients and serialize it back to return data. + +## The User SQLAlchemy model + +```python title="models/user.py" +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) +``` + +Let's also add this class to `models/__init__.py` so it can then be imported by `app.py`: + +```python title="models/__init__.py" +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags +``` + +## The User marshmallow schema + +```python title="schemas.py" +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) +``` \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.dockerignore b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flake8 b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flaskenv b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/Dockerfile b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/app.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/app.py new file mode 100644 index 00000000..c22daf8c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/app.py @@ -0,0 +1,41 @@ +from flask import Flask +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/conftest.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/db.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/__init__.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/item.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/item_tags.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/store.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/tag.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/user.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/requirements.txt b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__init__.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/item.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/store.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/tag.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/schemas.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.dockerignore b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flake8 b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flaskenv b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/Dockerfile b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/app.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/app.py new file mode 100644 index 00000000..c22daf8c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/app.py @@ -0,0 +1,41 @@ +from flask import Flask +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/conftest.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/db.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/__init__.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/item.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/item_tags.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/store.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/tag.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/requirements.txt b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__init__.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/item.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/store.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/tag.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/schemas.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/schemas.py new file mode 100644 index 00000000..99b9c94a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/schemas.py @@ -0,0 +1,45 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/README.md b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/README.md new file mode 100644 index 00000000..2ac1677f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/README.md @@ -0,0 +1,112 @@ +--- +title: How to add a register endpoint to the REST API +description: Learn how to add a registration endpoint to a REST API using Flask-Smorest and Flask-JWT-Extended. +--- + +# How to add a register endpoint to the REST API + +Registering users sounds like a conceptually very difficult thing, but let's break it down into steps: + +- Receive username and password from the client (as JSON). +- Check if a user with that username already exists. +- If it doesn't... + - Encrypt the password. + - Add a new `UserModel` to the database. + - Return a success message. + +## Boilerplate set-up for a blueprint with Flask-Smorest + +First, we need our imports and blueprint set-up. This is the same for pretty much every Flask-Smorest blueprint, so you already know how to do it! + +```python title="resources/user.py" +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") +``` + +## Creating the `UserRegister` resource + +Now let's create the `MethodView` class, and register a route to it using the blueprint: + +```python title="resources/user.py" +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +# highlight-start +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 +# highlight-end +``` + +## Creating a testing-only `User` resource + +Let's also create a `User` resource that we will only use during testing. It allows us to retrieve information about a single user, or delete a user. This will be handy so that using Insomnia or Postman we can clear the registered users and we don't have to change our request arguments each time! + +```python title="resources/user.py" +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 +``` + +## Register the user blueprint in `app.py` + +Finally, let's go to `app.py` and register the blueprint! + +```diff title="app.py" ++from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + +... + ++api.register_blueprint(UserBlueprint) +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) +api.register_blueprint(TagBlueprint) +``` \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.dockerignore b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flake8 b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flaskenv b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/Dockerfile b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/app.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/app.py new file mode 100644 index 00000000..20fa6086 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/app.py @@ -0,0 +1,43 @@ +from flask import Flask +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/conftest.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/db.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/__init__.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/item.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/item_tags.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/store.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/tag.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/user.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/requirements.txt b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__init__.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_user.py new file mode 100644 index 00000000..a9171782 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_user.py @@ -0,0 +1,72 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/item.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/store.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/tag.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/user.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/user.py new file mode 100644 index 00000000..0042abc4 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/user.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/schemas.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.dockerignore b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flake8 b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flaskenv b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/Dockerfile b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/app.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/app.py new file mode 100644 index 00000000..c22daf8c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/app.py @@ -0,0 +1,41 @@ +from flask import Flask +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/conftest.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/db.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/__init__.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/item.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/item_tags.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/store.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/tag.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/user.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/requirements.txt b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__init__.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/item.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/store.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/tag.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/schemas.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/README.md b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/README.md new file mode 100644 index 00000000..247e5f25 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/README.md @@ -0,0 +1,37 @@ +--- +title: How to add a login endpoint to the REST API +description: Learn how to add a login endpoint that returns a JWT to a REST API using Flask-Smorest and Flask-JWT-Extended. +--- + +# How to add a login endpoint to the REST API + +Now that we've done registration, we can do log in! It's very similar. + +Let's import `flask_jwt_extended.create_access_token` so that when we receive a valid username and password from the client, we can create a JWT and send it back: + +```diff title="resources/user.py" +from flask.views import MethodView +from flask_smorest import Blueprint, abort ++from flask_jwt_extended import create_access_token +from passlib.hash import pbkdf2_sha256 +``` + +Then let's create our `UserLogin` resource. + +```python title="resources/user.py" +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") +``` + +Here you can see the when we call `create_access_token(identity=user.id)` we pass in the user's `id`. This is what gets stored (among other things) inside the JWT, so when the client sends the JWT back on every request, we can tell who the JWT belongs to. \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.dockerignore b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flake8 b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flaskenv b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/Dockerfile b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/app.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/app.py new file mode 100644 index 00000000..20fa6086 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/app.py @@ -0,0 +1,43 @@ +from flask import Flask +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/conftest.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/db.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/__init__.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/item.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/item_tags.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/store.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/tag.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/user.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/requirements.txt b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__init__.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_user.py new file mode 100644 index 00000000..ed15d09b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_user.py @@ -0,0 +1,116 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwt(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/item.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/store.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/tag.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/user.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/user.py new file mode 100644 index 00000000..2712bb3e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/user.py @@ -0,0 +1,64 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import create_access_token +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/schemas.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.dockerignore b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flake8 b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flaskenv b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/Dockerfile b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/app.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/app.py new file mode 100644 index 00000000..20fa6086 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/app.py @@ -0,0 +1,43 @@ +from flask import Flask +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/conftest.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/db.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/__init__.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/item.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/item_tags.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/store.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/tag.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/user.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/requirements.txt b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__init__.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_user.py new file mode 100644 index 00000000..a9171782 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_user.py @@ -0,0 +1,72 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/item.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/store.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/tag.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/user.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/user.py new file mode 100644 index 00000000..0042abc4 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/user.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/schemas.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/README.md b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/README.md new file mode 100644 index 00000000..b041cbbb --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/README.md @@ -0,0 +1,153 @@ +--- +title: Protect endpoints by requiring a JWT +description: Use jwt_required from Flask-JWT-Extended to prevent unauthorised users from making requests to certain endpoints in a REST API. +--- + +# Protect endpoints by requiring a JWT + +Now that our users can sign up and log in, that means we can start _requiring login_ for certain endpoints. + +All this means in practice is that the client making the request must send a valid JWT. + +Remember, we can tell if a JWT is valid because it is _signed by our app_. If the user changes the JWT at all, the signature will be invalid, and we'll know it has been tampered with. Flask-JWT-Extended takes care of all that for us. + +## Protecting routes in the `Item` resource + +```python title="resources/item.py" +from flask.views import MethodView +from flask_smorest import Blueprint, abort +# highlight-start +from flask_jwt_extended import jwt_required +# highlight-end +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + # highlight-start + @jwt_required() + # highlight-end + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + # highlight-start + @jwt_required() + # highlight-end + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get_or_404(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(**item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + # highlight-start + @jwt_required() + # highlight-end + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + # highlight-start + @jwt_required() + # highlight-end + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item +``` + +## Error handling with Flask-JWT-Extended + +There are many things that could go wrong with JWTs: + +- The JWT may be expired (they don't last forever!) +- The JWT may be invalid, such as if the client makes changes to it +- A JWT may be required, but none was provided +- There's more (we'll look at them in coming lectures!) + +Let's go to `app.py` and add some configuration to tell Flask-JWT-Extended what to do in each of these cases. + +At the top, let's import `jsonify`: + +```python title="app.py" +from flask import Flask, jsonify +``` + +Then, after we define the `jwt = JWTManager(app)` variable, we can write some functions, each of which can run in different problem scenarios. + +```python title="app.py" +... + +app.config["JWT_SECRET_KEY"] = "jose" +jwt = JWTManager(app) + +@jwt.expired_token_loader +def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + +@jwt.invalid_token_loader +def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + +@jwt.unauthorized_loader +def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + +... +``` + +:::tip +Note that some Flask-JWT-Extended error functions take two arguments: `jwt_header` and `jwt_payload`. Others take a single argument, `error`. + +The ones that don't take JWT information are those that would be called when a JWT is not present (above, when the JWT is invalid or required but not received). +::: \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.dockerignore b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flake8 b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flaskenv b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/Dockerfile b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/app.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/app.py new file mode 100644 index 00000000..b486d312 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/app.py @@ -0,0 +1,82 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + # JWT configuration ends + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/conftest.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/conftest.py new file mode 100644 index 00000000..a3328c07 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/conftest.py @@ -0,0 +1,27 @@ +import pytest +from flask_jwt_extended import create_access_token +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def jwt(app): + with app.app_context(): + access_token = create_access_token(identity=1) + return access_token diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/db.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/__init__.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/item.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/item_tags.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/store.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/tag.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/user.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/requirements.txt b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__init__.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/conftest.py new file mode 100644 index 00000000..02618815 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/conftest.py @@ -0,0 +1,32 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_item.py new file mode 100644 index 00000000..0ab2b383 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_item.py @@ -0,0 +1,132 @@ +def test_create_item_in_store(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client, jwt): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client, jwt): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, jwt, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client, jwt): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client, jwt): + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, jwt, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client, jwt): + response = client.get( + "/item/1", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_store.py new file mode 100644 index 00000000..b69a498b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_store.py @@ -0,0 +1,220 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, jwt, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id, jwt): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client, jwt): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_user.py new file mode 100644 index 00000000..ed15d09b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_user.py @@ -0,0 +1,116 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwt(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/item.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/item.py new file mode 100644 index 00000000..1de1f7a5 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/item.py @@ -0,0 +1,64 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required() + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/store.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/tag.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/user.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/user.py new file mode 100644 index 00000000..2712bb3e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/user.py @@ -0,0 +1,64 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import create_access_token +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/schemas.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.dockerignore b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flake8 b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flaskenv b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/Dockerfile b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/app.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/app.py new file mode 100644 index 00000000..20fa6086 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/app.py @@ -0,0 +1,43 @@ +from flask import Flask +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/conftest.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/db.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/__init__.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/item.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/item_tags.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/store.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/tag.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/user.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/requirements.txt b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__init__.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_user.py new file mode 100644 index 00000000..ed15d09b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_user.py @@ -0,0 +1,116 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwt(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/item.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/store.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/tag.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/user.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/user.py new file mode 100644 index 00000000..2712bb3e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/user.py @@ -0,0 +1,64 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import create_access_token +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/schemas.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/README.md b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/README.md new file mode 100644 index 00000000..e739d01b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/README.md @@ -0,0 +1,63 @@ +--- +title: JWT claims and authorization +description: Learn how to add claims (extra info) to a JWT and use it for authorization in endpoints of a REST API. +--- + +# JWT Claims and Authorization + +JWT claims are extra data we can add to the JWT. For example, we could store in the JWT whether the user whose ID is stored in the JWT is an "administrator" or not. + +By doing this, we only have to check the user's permissions once, when we create the JWT, and not every time the user makes a request. + +To add a custom claim to a JWT we define a function similar to the error handling functions we wrote in the last lecture: + +```python title="app.py" +app.config["JWT_SECRET_KEY"] = "jose" +jwt = JWTManager(app) + +# highlight-start +@jwt.additional_claims_loader +def add_claims_to_jwt(identity): + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} +# highlight-end + +@jwt.expired_token_loader +def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) +``` + +:::caution Read from a database or config file +Here we're assuming that the user with and ID of `1` will be the administrator. Normally you'd read this from either a config file or the database. +::: + +## How to use JWT claims in an endpoint + +Let's make a small change to the `Item` resource so that only admins can delete items. + +To do so, we'll need to add an import for `get_jwt`: + +```python title="resources/item.py" +from flask_jwt_extended import jwt_required, get_jwt +``` + +Then in the `delete` endpoint, we can use `get_jwt()` to check the data in the JWT (which behaves like a dictionary): + +```python title="resources/item.py" +@jwt_required() +def delete(self, item_id): + # highlight-start + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + # highlight-end + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} +``` \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.dockerignore b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flake8 b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flaskenv b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/Dockerfile b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/app.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/app.py new file mode 100644 index 00000000..e0e6c6f3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/app.py @@ -0,0 +1,89 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + # JWT configuration ends + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/conftest.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/conftest.py new file mode 100644 index 00000000..ca4fed7e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/conftest.py @@ -0,0 +1,36 @@ +import pytest +from flask_jwt_extended import create_access_token +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def jwt(app): + with app.app_context(): + access_token = create_access_token(identity=1) + return access_token + + +@pytest.fixture() +def admin_jwt(app): + with app.app_context(): + access_token = create_access_token( + identity=1, additional_claims={"is_admin": True} + ) + return access_token diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/db.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/__init__.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/item.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/item_tags.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/store.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/tag.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/user.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/requirements.txt b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__init__.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/conftest.py new file mode 100644 index 00000000..02618815 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/conftest.py @@ -0,0 +1,32 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_item.py new file mode 100644 index 00000000..8ef26fd7 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_item.py @@ -0,0 +1,142 @@ +def test_create_item_in_store(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client, jwt): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client, jwt): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, admin_jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_delete_item_without_admin(client, jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Admin privilege required." + + +def test_update_item(client, jwt, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client, jwt): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client, jwt): + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, jwt, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client, jwt): + response = client.get( + "/item/1", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_store.py new file mode 100644 index 00000000..b69a498b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_store.py @@ -0,0 +1,220 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, jwt, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id, jwt): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client, jwt): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_user.py new file mode 100644 index 00000000..ed15d09b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_user.py @@ -0,0 +1,116 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwt(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/item.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/item.py new file mode 100644 index 00000000..379c6f48 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/item.py @@ -0,0 +1,68 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required() + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/store.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/tag.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/user.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/user.py new file mode 100644 index 00000000..2712bb3e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/user.py @@ -0,0 +1,64 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import create_access_token +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/schemas.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.dockerignore b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flake8 b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flaskenv b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/Dockerfile b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/app.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/app.py new file mode 100644 index 00000000..b486d312 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/app.py @@ -0,0 +1,82 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + # JWT configuration ends + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/conftest.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/conftest.py new file mode 100644 index 00000000..a3328c07 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/conftest.py @@ -0,0 +1,27 @@ +import pytest +from flask_jwt_extended import create_access_token +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def jwt(app): + with app.app_context(): + access_token = create_access_token(identity=1) + return access_token diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/db.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/__init__.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/item.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/item_tags.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/store.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/tag.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/user.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/requirements.txt b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__init__.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/conftest.py new file mode 100644 index 00000000..02618815 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/conftest.py @@ -0,0 +1,32 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_item.py new file mode 100644 index 00000000..0ab2b383 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_item.py @@ -0,0 +1,132 @@ +def test_create_item_in_store(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client, jwt): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client, jwt): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, jwt, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client, jwt): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client, jwt): + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, jwt, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client, jwt): + response = client.get( + "/item/1", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_store.py new file mode 100644 index 00000000..b69a498b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_store.py @@ -0,0 +1,220 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, jwt, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id, jwt): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client, jwt): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_user.py new file mode 100644 index 00000000..ed15d09b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_user.py @@ -0,0 +1,116 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwt(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/item.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/item.py new file mode 100644 index 00000000..1de1f7a5 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/item.py @@ -0,0 +1,64 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required() + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/store.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/tag.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/user.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/user.py new file mode 100644 index 00000000..2712bb3e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/user.py @@ -0,0 +1,64 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import create_access_token +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/schemas.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/README.md b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/README.md new file mode 100644 index 00000000..e4f46fdd --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/README.md @@ -0,0 +1,91 @@ +--- +title: How to add logout to the REST API +description: Create a logout endpoint that blocks certain JWTs from making further authenticated requests. +--- + +# How to add logout to the REST API + +To log an user out we must _revoke_ their JWT. That way, if they send us the same JWT again, we can check whether it's been revoked or not. If it has, then we won't authorize them. + +To do this, we need a central store of revoked JWTs that we keep around at least until the revoked JWT has expired. + +Let's create our central revoked JWT storage in a file called `blocklist.py`. You could store this in the database instead, if you prefer. I'll leave that as an exercise for you. + +```python title="blocklist.py" +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() +``` + +## Flask-JWT-Extended blocklist configuration for user logout + +Now, in `app.py`, let's add some more Flask-JWT-Extended configuration to do two things: + +- Check whether any JWT received is in the blocklist. +- If they are, return an error message to that effect. + +```python title="app.py" +from blocklist import BLOCKLIST + +... + +@jwt.token_in_blocklist_loader +def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + +@jwt.revoked_token_loader +def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) +``` + +## How to perform logout (i.e. add JWTs to the blocklist) + +Finally we need a resource in `resources/user.py` to actually add the user's JWT to the blocklist when they log out. + +```python title="resources/user.py" +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + # highlight-start + get_jwt, + jwt_required, + # highlight-end +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +# highlight-start +from blocklist import BLOCKLIST +# highlight-end + + +blp = Blueprint("Users", "users", description="Operations on users") + +# highlight-start +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 +# highlight-end + + +# Other User routes here +``` \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.dockerignore b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flake8 b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flaskenv b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/Dockerfile b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/app.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/app.py new file mode 100644 index 00000000..3535e15d --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/app.py @@ -0,0 +1,94 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + # JWT configuration ends + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/blocklist.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/conftest.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/conftest.py new file mode 100644 index 00000000..ca4fed7e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/conftest.py @@ -0,0 +1,36 @@ +import pytest +from flask_jwt_extended import create_access_token +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def jwt(app): + with app.app_context(): + access_token = create_access_token(identity=1) + return access_token + + +@pytest.fixture() +def admin_jwt(app): + with app.app_context(): + access_token = create_access_token( + identity=1, additional_claims={"is_admin": True} + ) + return access_token diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/db.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/__init__.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/item.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/item_tags.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/store.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/tag.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/user.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/requirements.txt b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__init__.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/conftest.py new file mode 100644 index 00000000..02618815 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/conftest.py @@ -0,0 +1,32 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_item.py new file mode 100644 index 00000000..8ef26fd7 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_item.py @@ -0,0 +1,142 @@ +def test_create_item_in_store(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client, jwt): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client, jwt): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, admin_jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_delete_item_without_admin(client, jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Admin privilege required." + + +def test_update_item(client, jwt, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client, jwt): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client, jwt): + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, jwt, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client, jwt): + response = client.get( + "/item/1", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_store.py new file mode 100644 index 00000000..b69a498b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_store.py @@ -0,0 +1,220 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, jwt, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id, jwt): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client, jwt): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_user.py new file mode 100644 index 00000000..6e4c70c1 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_user.py @@ -0,0 +1,165 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwt(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_logout_user(client, created_user_jwt): + response = client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Successfully logged out" + + +def test_logout_user_twice(client, created_user_jwt): + client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwt}"}, + ) + response = client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwt}"}, + ) + + assert response.status_code == 401 + assert response.json == { + "description": "The token has been revoked.", + "error": "token_revoked", + } + + +def test_logout_user_no_token(client): + response = client.post( + "/logout", + ) + + assert response.status_code == 401 + assert response.json["description"] == "Request does not contain an access token." + + +def test_logout_user_invalid_token(client): + response = client.post( + "/logout", + headers={"Authorization": "Bearer bad_token"}, + ) + + assert response.status_code == 401 + assert response.json == { + "error": "invalid_token", + "message": "Signature verification failed.", + } + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/item.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/item.py new file mode 100644 index 00000000..379c6f48 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/item.py @@ -0,0 +1,68 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required() + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/store.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/tag.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/user.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/user.py new file mode 100644 index 00000000..1360b2c8 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/user.py @@ -0,0 +1,78 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/schemas.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.dockerignore b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flake8 b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flaskenv b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/Dockerfile b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/app.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/app.py new file mode 100644 index 00000000..589a9391 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/app.py @@ -0,0 +1,80 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + # JWT configuration ends + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/conftest.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/conftest.py new file mode 100644 index 00000000..ca4fed7e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/conftest.py @@ -0,0 +1,36 @@ +import pytest +from flask_jwt_extended import create_access_token +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def jwt(app): + with app.app_context(): + access_token = create_access_token(identity=1) + return access_token + + +@pytest.fixture() +def admin_jwt(app): + with app.app_context(): + access_token = create_access_token( + identity=1, additional_claims={"is_admin": True} + ) + return access_token diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/db.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/__init__.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/item.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/item_tags.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/store.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/tag.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/user.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/requirements.txt b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__init__.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/conftest.py new file mode 100644 index 00000000..02618815 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/conftest.py @@ -0,0 +1,32 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_item.py new file mode 100644 index 00000000..8ef26fd7 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_item.py @@ -0,0 +1,142 @@ +def test_create_item_in_store(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client, jwt): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client, jwt): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, admin_jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_delete_item_without_admin(client, jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Admin privilege required." + + +def test_update_item(client, jwt, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client, jwt): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client, jwt): + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, jwt, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client, jwt): + response = client.get( + "/item/1", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_store.py new file mode 100644 index 00000000..b69a498b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_store.py @@ -0,0 +1,220 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, jwt, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id, jwt): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client, jwt): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_user.py new file mode 100644 index 00000000..ed15d09b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_user.py @@ -0,0 +1,116 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwt(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/item.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/item.py new file mode 100644 index 00000000..379c6f48 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/item.py @@ -0,0 +1,68 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required() + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/store.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/tag.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/user.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/user.py new file mode 100644 index 00000000..2712bb3e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/user.py @@ -0,0 +1,64 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import create_access_token +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/schemas.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/11_insomnia_request_chaining/README.md b/docs/docs/08_flask_jwt_extended/11_insomnia_request_chaining/README.md new file mode 100644 index 00000000..c83aa492 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/11_insomnia_request_chaining/README.md @@ -0,0 +1,8 @@ +--- +title: Insomnia request chaining +description: "Learn how to use Insomnia's Request Chaining to simplify our workflow and not have to copy-paste the access token in every request." +--- + +# Request chaining with Insomnia + +Nothing here yet! diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/README.md b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/README.md new file mode 100644 index 00000000..a9cb2004 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/README.md @@ -0,0 +1,133 @@ +--- +title: Token refreshing with Flask-JWT-Extended +description: Learn about fresh and non-fresh tokens, as well as how to use a refresh token to generate a new, non-fresh access token. +--- + +# Token refreshing with Flask-JWT-Extended + +One of the problems with JWT authentication is that JWTs expire, and then the user has to re-authenticate by providing their username and password. + +How long to set the JWT expiry time is tricky. If it's very long, it's more likely that a different person may use the same device to access the website, and the previous user's account will still be logged in. If it's very short though, it's really annoying for users. + +This is where the concept of **token refreshing** comes into play. + +We can provide our users two tokens: an **access token** that they can use to, well, access endpoints, and a **refresh token** that they can use to get a new access token without having to provide their username and password. + +:::tip When do clients use the refresh token? +When a client makes a request and sends the access token, if the token has expired our API sends back a message to that effect. + +At that point, the client can then, behind the scenes and without the user noticing, use the refresh to get a new access token, and re-request the original page. + +For a client, the authentication flow is a three-step process: + +1. Send the access token they've got stored (may or may not be fresh). +2. If API responds with a 401 Unauthorized, use the refresh token to get a new access token and try again. Now you've got a new, non-fresh access token. +3. If the API responds with another 401 Unauthorized, ask the user to log in again. Now you've got a fresh access token. +::: + +The important thing here is **token freshness**. + +- A **fresh access token** is given to users immediately after logging in. +- A **non-fresh access token** is given to users when they use their refresh token. + +This is important, because it means that we can protect certain routes by requiring a fresh access token. Since these tokens are only generated in response to login, we know that the user is probably who they say they are, and they haven't simply forgotten to log out. + +As an example, if the user goes to their "delete my account" page, we might want a fresh token to access that endpoint. However, if they're simply going to their profile page, we may accept a non-fresh token. + +## How to create refresh tokens with Flask-JWT-Extended + +When a user logs in, we can create the access token and the refresh token at the same time. We will also make sure that the access token is marked as **fresh**. + +First, let's add new imports: + +```diff title="resources/user.py" +from flask_jwt_extended import ( + create_access_token, ++ create_refresh_token, ++ get_jwt_identity, + get_jwt, + jwt_required, +) +``` + +Then let's change our `UserLogin` route: + +```python title="resources/user.py" +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + # highlight-start + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + # highlight-end + + abort(401, message="Invalid credentials.") +``` + +## Writing the token refresh endpoint + +When a user logs in, they will now have the access token and the refresh token. + +Let's code another endpoint that will take the refresh token and return a new, non-fresh access token: + +```python title="resources/user.py" +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 +``` + +Note that above, we've told Flask-JWT-Extended that a refresh token is required with `@jwt_required(refresh=True)`. We'll do something similar for requiring fresh tokens in a second! + +## Requiring a fresh token for certain endpoints + +Let's go to the create item endpoint and mark it as needing a fresh token. Normally, fresh tokens would be required for destructive operations such as changing passwords or deleting accounts. + +```python title="resources/item.py" +# highlight-start +@jwt_required(fresh=True) +# highlight-end +@blp.arguments(ItemSchema) +@blp.response(201, ItemSchema) +def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item +``` + +## Error handling when a fresh token is required + +When a fresh token is required but a non-fresh token is provided, we want the Flask app to return a message to that effect. We can do this just as we did with the other Flask-JWT-Extended configurations: + +```python title="app.py" +@jwt.needs_fresh_token_loader +def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) +``` \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.dockerignore b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flake8 b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flaskenv b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/Dockerfile b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/app.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/app.py new file mode 100644 index 00000000..3a527c74 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/app.py @@ -0,0 +1,106 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + # JWT configuration ends + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/blocklist.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/conftest.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/conftest.py new file mode 100644 index 00000000..3f6d6ea6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/conftest.py @@ -0,0 +1,43 @@ +import pytest +from flask_jwt_extended import create_access_token +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def fresh_jwt(app): + with app.app_context(): + access_token = create_access_token(identity=1, fresh=True) + return access_token + + +@pytest.fixture() +def jwt(app): + with app.app_context(): + access_token = create_access_token(identity=1) + return access_token + + +@pytest.fixture() +def admin_jwt(app): + with app.app_context(): + access_token = create_access_token( + identity=1, additional_claims={"is_admin": True} + ) + return access_token diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/db.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/__init__.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/item.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/item_tags.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/store.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/tag.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/user.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/requirements.txt b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__init__.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/conftest.py new file mode 100644 index 00000000..d5d75e95 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/conftest.py @@ -0,0 +1,32 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, fresh_jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_item.py new file mode 100644 index 00000000..b3647125 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_item.py @@ -0,0 +1,156 @@ +def test_create_item_in_store(client, fresh_jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client, fresh_jwt): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client, fresh_jwt): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_create_item_with_non_fresh_jwt(client, jwt): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 401 + assert response.json == { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + + +def test_delete_item(client, admin_jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_delete_item_without_admin(client, jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Admin privilege required." + + +def test_update_item(client, jwt, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client, fresh_jwt, jwt): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client, jwt): + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, jwt, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client, jwt): + response = client.get( + "/item/1", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_store.py new file mode 100644 index 00000000..a4a23d48 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_store.py @@ -0,0 +1,220 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, fresh_jwt, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id, fresh_jwt): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client, fresh_jwt): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {fresh_jwt}"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_user.py new file mode 100644 index 00000000..a80056cd --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_user.py @@ -0,0 +1,201 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwts(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"], response.json["refresh_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_logout_user(client, created_user_jwts): + response = client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwts[0]}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Successfully logged out" + + +def test_logout_user_twice(client, created_user_jwts): + client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwts[0]}"}, + ) + response = client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwts[0]}"}, + ) + + assert response.status_code == 401 + assert response.json == { + "description": "The token has been revoked.", + "error": "token_revoked", + } + + +def test_logout_user_no_token(client): + response = client.post( + "/logout", + ) + + assert response.status_code == 401 + assert response.json["description"] == "Request does not contain an access token." + + +def test_logout_user_invalid_token(client): + response = client.post( + "/logout", + headers={"Authorization": "Bearer bad_token"}, + ) + + assert response.status_code == 401 + assert response.json == { + "error": "invalid_token", + "message": "Signature verification failed.", + } + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_refresh_token_invalid(client): + response = client.post( + "/refresh", + headers={"Authorization": "Bearer bad_jwt"}, + ) + + assert response.status_code == 401 + + +def test_refresh_token(client, created_user_jwts): + response = client.post( + "/refresh", + headers={"Authorization": f"Bearer {created_user_jwts[1]}"}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_refresh_token_twice(client, created_user_jwts): + client.post( + "/refresh", + headers={"Authorization": f"Bearer {created_user_jwts[1]}"}, + ) + response = client.post( + "/refresh", + headers={"Authorization": f"Bearer {created_user_jwts[1]}"}, + ) + + assert response.status_code == 401 + assert response.json == { + "description": "The token has been revoked.", + "error": "token_revoked", + } diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/item.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/item.py new file mode 100644 index 00000000..28e9b186 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/item.py @@ -0,0 +1,68 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/store.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/tag.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/user.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/user.py new file mode 100644 index 00000000..e21a8595 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/user.py @@ -0,0 +1,93 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/schemas.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.dockerignore b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flake8 b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flake8 new file mode 100644 index 00000000..3c4d6568 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flake8 @@ -0,0 +1,2 @@ +[flake8] +per-file-ignores = __init__.py:F401 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flaskenv b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/Dockerfile b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/Dockerfile new file mode 100644 index 00000000..00c70b5b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/app.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/app.py new file mode 100644 index 00000000..3535e15d --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/app.py @@ -0,0 +1,94 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + # JWT configuration ends + + @app.before_first_request + def create_tables(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/blocklist.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/conftest.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/conftest.py new file mode 100644 index 00000000..ca4fed7e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/conftest.py @@ -0,0 +1,36 @@ +import pytest +from flask_jwt_extended import create_access_token +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def jwt(app): + with app.app_context(): + access_token = create_access_token(identity=1) + return access_token + + +@pytest.fixture() +def admin_jwt(app): + with app.app_context(): + access_token = create_access_token( + identity=1, additional_claims={"is_admin": True} + ) + return access_token diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/db.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/__init__.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/item.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/item_tags.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/store.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/tag.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/user.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/user.py new file mode 100644 index 00000000..554e6775 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/requirements-dev.txt b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/requirements-dev.txt new file mode 100644 index 00000000..fe3757d9 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +black +flake8 \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/requirements.txt b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__init__.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/conftest.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/conftest.py new file mode 100644 index 00000000..02618815 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/conftest.py @@ -0,0 +1,32 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_item.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_item.py new file mode 100644 index 00000000..8ef26fd7 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_item.py @@ -0,0 +1,142 @@ +def test_create_item_in_store(client, jwt, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client, jwt): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client, jwt): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, admin_jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_delete_item_without_admin(client, jwt, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Admin privilege required." + + +def test_update_item(client, jwt, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client, jwt): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client, jwt): + response = client.get( + "/item", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, jwt, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client, jwt): + response = client.get( + "/item/1", + headers={"Authorization": f"Bearer {jwt}"}, + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_store.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_store.py new file mode 100644 index 00000000..b69a498b --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_store.py @@ -0,0 +1,220 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, jwt, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id, jwt): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client, jwt): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + headers={"Authorization": f"Bearer {jwt}"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_tag.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_user.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_user.py new file mode 100644 index 00000000..6e4c70c1 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_user.py @@ -0,0 +1,165 @@ +import pytest + + +@pytest.fixture() +def created_user_details(client): + username = "test_user" + password = "test_password" + client.post( + "/register", + json={"username": username, "password": password}, + ) + + return username, password + + +@pytest.fixture() +def created_user_jwt(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + return response.json["access_token"] + + +def test_register_user(client): + username = "test_user" + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 201 + assert response.json == {"message": "User created successfully."} + + +def test_register_user_already_exists(client): + username = "test_user" + client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + response = client.post( + "/register", + json={"username": username, "password": "Test Password"}, + ) + + assert response.status_code == 409 + assert response.json["message"] == "A user with that username already exists." + + +def test_register_user_missing_data(client): + response = client.post( + "/register", + json={}, + ) + + assert response.status_code == 422 + assert "password" in response.json["errors"]["json"] + assert "username" in response.json["errors"]["json"] + + +def test_login_user(client, created_user_details): + username, password = created_user_details + response = client.post( + "/login", + json={"username": username, "password": password}, + ) + + assert response.status_code == 200 + assert response.json["access_token"] + + +def test_login_user_bad_password(client, created_user_details): + username, _ = created_user_details + response = client.post( + "/login", + json={"username": username, "password": "bad_password"}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_login_user_bad_username(client, created_user_details): + _, password = created_user_details + response = client.post( + "/login", + json={"username": "bad_username", "password": password}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Invalid credentials." + + +def test_logout_user(client, created_user_jwt): + response = client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwt}"}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Successfully logged out" + + +def test_logout_user_twice(client, created_user_jwt): + client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwt}"}, + ) + response = client.post( + "/logout", + headers={"Authorization": f"Bearer {created_user_jwt}"}, + ) + + assert response.status_code == 401 + assert response.json == { + "description": "The token has been revoked.", + "error": "token_revoked", + } + + +def test_logout_user_no_token(client): + response = client.post( + "/logout", + ) + + assert response.status_code == 401 + assert response.json["description"] == "Request does not contain an access token." + + +def test_logout_user_invalid_token(client): + response = client.post( + "/logout", + headers={"Authorization": "Bearer bad_token"}, + ) + + assert response.status_code == 401 + assert response.json == { + "error": "invalid_token", + "message": "Signature verification failed.", + } + + +def test_get_user_details(client, created_user_details): + response = client.get( + "/user/1", # assume user id is 1 + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "username": created_user_details[0], + } + + +def test_get_user_details_missing(client): + response = client.get( + "/user/23", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/item.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/item.py new file mode 100644 index 00000000..379c6f48 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/item.py @@ -0,0 +1,68 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required() + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/store.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/tag.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/user.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/user.py new file mode 100644 index 00000000..1360b2c8 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/user.py @@ -0,0 +1,78 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id) + return {"access_token": access_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/schemas.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/schemas.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) diff --git a/docs/docs/08_flask_jwt_extended/_category_.json b/docs/docs/08_flask_jwt_extended/_category_.json new file mode 100644 index 00000000..d0192303 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "User Authentication with Flask-JWT-Extended", + "position": 8 +} diff --git a/docs/docs/09_flask_migrate/01_why_use_database_migrations/README.md b/docs/docs/09_flask_migrate/01_why_use_database_migrations/README.md new file mode 100644 index 00000000..8c2d4df4 --- /dev/null +++ b/docs/docs/09_flask_migrate/01_why_use_database_migrations/README.md @@ -0,0 +1,18 @@ +--- +title: Why use database migrations? +description: Learn about database migrations and what they are useful for. +--- + +# Why use database migrations? + +As you work on your application, particularly over a long time, it is unavoidable that you will want to add columns to your models, or even add new models entirely. + +Making the changes directly to the models without something like Alembic and Flask-Migrate will mean that the existing database tables and the model definitions will be out of sync. When that happens, SQLAlchemy usually complains and your application won't work. + +An option is to delete everything and get SQLAlchemy to re-create the tables. Obviously, this is not good if you have data in the database as you would lose all the data. + +We can use Alembic to detect the changes to the models, and what steps are necessary to "upgrade" the database so it matches the new models. Then we can use Alembic to actually modify the database following the upgrade steps. + +Alembic also tracks each of these migrations over time, so that you can easily go to a past version of the database. This is useful if bugs are introduced or the feature requirements change. + +Since Alembic tracks all the migrations over time, we can use it to create the tables from scratch, simply by applying the migrations one at a time until we reach the latest one (which should be equal to the current one). \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/README.md b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/README.md new file mode 100644 index 00000000..2a157108 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/README.md @@ -0,0 +1,39 @@ +--- +title: How to add Flask-Migrate to our Flask app +description: Integrating your Flask app with Flask-Migrate is relatively straightforward. Learn how to do it in this lecture. +--- + +# How to add Flask-Migrate to our Flask app + +Adding Flask-Migrate to our app is simple, just install it and add a couple lines to `app.py`. + +To install: + +``` +pip install flask-migrate +``` + +This will also install Alembic, since it is a dependency. + +Then we need to add 2 lines to `app.py` (highlighted): + +```py +from flask_smorest import Api +# highlight-start +from flask_migrate import Migrate +# highlight-end + +import models + +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["PROPAGATE_EXCEPTIONS"] = True +db.init_app(app) +# highlight-start +migrate = Migrate(app, db) +# highlight-end +api = Api(app) + +@app.before_first_request +def create_tables(): + db.create_all() +``` \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/README.md b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/README.md new file mode 100644 index 00000000..8f8d5316 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/README.md @@ -0,0 +1,54 @@ +--- +title: Initialize your database with Flask-Migrate +description: "Learn the first steps when starting with Flask-Migrate: initializing the database." +--- + +# Initialize the database with Flask-Migrate + +Activate your virtual environment and run this command: + +``` +flask db init +``` + +This will create a `migrations` folder inside your project folder. + +In the `migrations` folder you'll find a few things: + +- The `versions` folder is where migration scripts will be placed. These will be used by Alembic to make changes to the database. +- `alembic.ini` is the Alembic configuration file. +- `env.py` is a script used by Alembic to generate migration files. +- `script.py.mako` is the template file for migration files. + +## Generate the first migration to set up the database + +Now that we're set up, we need to make sure that the database we want to use is currently empty. In our case, since we're using SQLite, just delete `data.db`. + +Then, run this command: + +``` +flask db migrate +``` + +This will create the migration file. + + +:::caution +It's important to double-check the migration script and make sure it is correct! Compare it with your model definitions and make sure nothing is missing. +::: + +Now let's actually apply the migration: + +``` +flask db upgrade +``` + +This will create the `data.db` file. If you were using another RDBMS (like PostgreSQL or MySQL), this command would create the tables using the existing model definitions. + +:::info How does the database know which version it's on? +When using Alembic to create the database tables from scratch, it creates an extra table with a single row, that stores the current migration version. + +You'll note in each migration script there is information about the previous migration and the next migration. + +This is why it's important to **never rename the migration files or change the revision identifiers at the top of those files**. +::: \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/README.md b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/README.md new file mode 100644 index 00000000..b245887c --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/README.md @@ -0,0 +1,46 @@ +--- +title: Change SQLAlchemy models and generate a migration +description: Use Flask-Migrate to generate a new database migration after changing your SQLAlchemy models. +--- + +# Change SQLAlchemy models and generate a migration + +Let's make a change to one of our SQLAlchemy models and then generate another migration script. This is what we will do every time we want to make changes to our models and our database schema. + +```python title="models/item.py" +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + # highlight-start + description = db.Column(db.String) + # highlight-end + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") +``` + +Here we're adding a simple column, just a string that doesn't have any constraints. + +Now let's go to the terminal and run the command: + +``` +flask db migrate +``` + +This will now generate _another migration script_ that you have to double-check. Make sure to check the upgrade and downgrade functions. + +When you're happy with the contents, apply the migration: + +``` +flask db upgrade +``` \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/05_manually_review_modify_migrations/README.md b/docs/docs/09_flask_migrate/05_manually_review_modify_migrations/README.md new file mode 100644 index 00000000..abeeeae2 --- /dev/null +++ b/docs/docs/09_flask_migrate/05_manually_review_modify_migrations/README.md @@ -0,0 +1,79 @@ +--- +title: Manually review and modify database migrations +description: Alembic can generate database migrations parting from model changes, but sometimes we need to modify them manually. +--- + +# Manually review and modify database migrations + +## Default column values + +When you add a column that uses a default value, any rows that existed previously will have `null` as the value. + +You'll have to go into the database to add the default value to those rows. + +You can also do this during the migration, since Alembic migrations can execute any arbitrary SQL queries. + +Here's an example for a column being added with a default value: + +```py title="migrations/versions/sample_migration.py" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "9c386e4052be" +down_revision = "713af8a4cb34" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "invoices", + sa.Column("enable_downloads", sa.Boolean(), nullable=True, default=False), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("invoices", "enable_downloads") + # ### end Alembic commands ### +``` + +You can see that we're adding a column called `enable_downloads` to the `invoices` table. The default value for new rows will be `False`, but what is the value for all the invoices we already have in the database? + +The value will be undefined, or `null`. + +What we must do is tell Alembic to insert `False` into each of the existing rows. We can do that with SQL: + +```py title="migrations/versions/sample_migration.py" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "9c386e4052be" +down_revision = "713af8a4cb34" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "invoices", + sa.Column("enable_downloads", sa.Boolean(), nullable=True, default=False), + ) + # highlight-start + op.execute("UPDATE invoices SET enable_downloads = False") + # highlight-end + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("invoices", "enable_downloads") + # ### end Alembic commands ### +``` \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/_category_.json b/docs/docs/09_flask_migrate/_category_.json new file mode 100644 index 00000000..df669545 --- /dev/null +++ b/docs/docs/09_flask_migrate/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Database migrations with Alembic and Flask-Migrate", + "position": 9 +} diff --git a/docs/docs/10_git_crash_course/README.md b/docs/docs/10_git_crash_course/README.md new file mode 100644 index 00000000..e3cf25e3 --- /dev/null +++ b/docs/docs/10_git_crash_course/README.md @@ -0,0 +1,5 @@ +# Git Crash Course + +The Git Crash Course e-book is hosted in a different page because it is used in multiple courses. + +Read the Git Crash Course e-book here: [https://git-workshop.tecladocode.com/](https://git-workshop.tecladocode.com/) \ No newline at end of file diff --git a/docs/docs/10_git_crash_course/_category_.json b/docs/docs/10_git_crash_course/_category_.json new file mode 100644 index 00000000..72d0f4c9 --- /dev/null +++ b/docs/docs/10_git_crash_course/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Git Crash Course", + "position": 10 +} diff --git a/docs/docs/11_deploy_to_render/01_section_overview/README.md b/docs/docs/11_deploy_to_render/01_section_overview/README.md new file mode 100644 index 00000000..f27a269f --- /dev/null +++ b/docs/docs/11_deploy_to_render/01_section_overview/README.md @@ -0,0 +1,13 @@ +# Overview of this section + +In this section, we will figure out how to get our Flask app and put it on a public server so other people can interact with it! This is called "deploying". + +There are many services we can use to deploy our app. Most of them have some sort of "free tier" so that you can try the deployment without having to pay anything. Usually, if you want better performance or unlimited usage, you have to pay. + +Remember that just as we run the Flask app in our computers, when we deploy it the app runs in a server, somewhere in the world. For all intents and purposes, the server is just like our computer! + +Servers usually run Linux, so we can deploy our Docker images without a performance hit as we would using Mac or Windows. + +At the end of the section, you'll be able to access your API using a URL such as [https://rest-api-smorest-docker.onrender.com](https://rest-api-smorest-docker.onrender.com). + +For this section, our deployment will be completely free. We will deploy our Flask app for free, and we will also get a free PostgreSQL database on the cloud using ElephantSQL. diff --git a/docs/docs/11_deploy_to_render/02_create_render_web_service/README.md b/docs/docs/11_deploy_to_render/02_create_render_web_service/README.md new file mode 100644 index 00000000..e322e81d --- /dev/null +++ b/docs/docs/11_deploy_to_render/02_create_render_web_service/README.md @@ -0,0 +1,47 @@ +# Creating a Render.com web service + +Let's start by going to [https://render.com](https://render.com) and signing up to an account. You can "Log in with GitHub" to make things easier. + +Once you've logged in, you'll see in your [Dashboard](https://dashboard.render.com/services) that you can create a new service using a button at the top right of the page. + +Click it, and select "Web Service". + +Options other than "Web Service" are useful for different kinds of applications, and some are databases that you can use (but not for free, so we won't use Render for our database in this section). + +Then you'll [connect your GitHub account](https://render.com/docs/github) if you haven't already, and look for your repositories. + +Select the repository that you created during this course: + +![Render.com screenshot showing how to search for and select a repository to connect to from GitHub](./assets/render-github-connect.png) + +Then, give it a name and make sure the configuration is as follows: + +![Render.com screenshot showing the web service configuration](./assets/render-service-config.png) + +- Make sure "Docker" is selected. +- Select a server location close to you. I'm near Frankfurt, but if you are in the US or Asia you might want to choose a different one so it's faster to connect to. +- Select the "Free" server option. + +At the bottom of the service there is an "Advanced" section which you can use to further configure your service. We'll talk more about that in a bit. + +For now, hit "Create Web Service" and wait for it to deploy your code from GitHub! + +If you navigate to your Dashboard and then click through to your newly created service, you'll be able to see the service details. If it isn't already deploying, click on the "Manual Deploy" button on the top right to initiate a deploy of the latest commit: + +![](./assets/deploy-latest-commit.png) + +Then you should start seeing logs appear detailing the deployment process! + +![](./assets/render-deploy-screen.png) + +While on the free plan, deployments are a bit slow. It has to build your image and run it! Give it a few minutes, until the deployment succeeds. You should see this: + +![](./assets/render-deploy-finished.png) + +Now, you can access your service URL and try it out using Insomnia or Postman! + +![](./assets/insomnia-test-prod.png) + +:::warning +Free services in Render.com shut down after inactivity for a few minutes. If you don't use your service for a few minutes, it will shut down and it will need to restart, which can take a minute! This is one of the limitations of their free plan. +::: diff --git a/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/deploy-latest-commit.png b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/deploy-latest-commit.png new file mode 100644 index 00000000..446f1aee Binary files /dev/null and b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/deploy-latest-commit.png differ diff --git a/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/insomnia-test-prod.png b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/insomnia-test-prod.png new file mode 100644 index 00000000..565fad3d Binary files /dev/null and b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/insomnia-test-prod.png differ diff --git a/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-deploy-finished.png b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-deploy-finished.png new file mode 100644 index 00000000..fdcb4322 Binary files /dev/null and b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-deploy-finished.png differ diff --git a/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-deploy-screen.png b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-deploy-screen.png new file mode 100644 index 00000000..f23548bf Binary files /dev/null and b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-deploy-screen.png differ diff --git a/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-github-connect.png b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-github-connect.png new file mode 100644 index 00000000..3d4d6709 Binary files /dev/null and b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-github-connect.png differ diff --git a/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-service-config.png b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-service-config.png new file mode 100644 index 00000000..093fa959 Binary files /dev/null and b/docs/docs/11_deploy_to_render/02_create_render_web_service/assets/render-service-config.png differ diff --git a/docs/docs/11_deploy_to_render/03_docker_with_gunicorn/README.md b/docs/docs/11_deploy_to_render/03_docker_with_gunicorn/README.md new file mode 100644 index 00000000..1047f353 --- /dev/null +++ b/docs/docs/11_deploy_to_render/03_docker_with_gunicorn/README.md @@ -0,0 +1,70 @@ +# Run our Flask app with gunicorn in Docker + +Throughout the course, we've been working with a Docker image like this one: + +```dockerfile +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] +``` + +This is all well and good for local development, but when we deploy our application we want to run it with the best performance possible. + +This is why we don't want to run the Flask development server and the Flask debugger. Instead, we'll use gunicorn to run our app. + +## Run our Flask app with gunicorn + +First let's add `gunicorn` to our `requirements.txt` file: + +```text title="requirements.txt" +flask +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +# highlight-start +gunicorn +# highlight-end +``` + +Then, let's change our `Dockerfile` to use `gunicorn`: + +```dockerfile +FROM python:3.10 +WORKDIR /app +COPY ./requirements.txt requirements.txt +# highlight-start +RUN pip install --no-cache-dir --upgrade -r requirements.txt +# highlight-end +COPY . . +# highlight-start +CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] +# highlight-end +``` + +The `CMD` line change is the important one, as it runs `gunicorn` on port `80`, and we pass in the app factory function. + +:::tip +Note I've also changed the `pip install` line. Adding `--no-cache-dir` and `--upgrade` just makes sure we can't accidentally install from a cache directory (which shouldn't exist anyway!), and that we'll upgrade to the latest possible versions allowed by our `requirements.txt` file. +::: + +## Run the Docker container locally with the Flask development server and debugger + +If you use this `Dockerfile`, it doesn't mean you can't run it locally using the Flask development server. You don't have to lose the automatic restarting capabilities, or the Flask debugger. + +To run the Docker container locally, you'll have to do this from now on: + +```zsh +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" teclado-site-flask sh -c "flask run" +``` + +This is similar to how we've ran the Docker container with our local code as a volume (that's what `-w /app -v "$(pwd):/app"` does), but at the end of the command we're telling the container to run `flask run` instead of the `CMD` line of the `Dockerfile`. That's what `sh -c "flask run"` does! + +Now you're ready to commit and push this to your repository and re-deploy to Render.com! diff --git a/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/README.md b/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/README.md new file mode 100644 index 00000000..2125552e --- /dev/null +++ b/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/README.md @@ -0,0 +1,17 @@ +# How to get a deployed PostgreSQL database for our app + +There are many PostgreSQL-as-a-Service providers (that means, services that handle creating and maintaining your database for you). + +Render.com offers PostgreSQL, and the pricing is actually quite fair. However, the free tier is limited and you can only use it for a certain amount of time. + +That's why I recommend using ElephantSQL for your free PostgreSQL needs. When you go over the free ElephantSQL limits, then you can use the Render.com database instead. + +To get a free ElephantSQL PostgreSQL database, just go to their site, sign up, and then create a database in a region close to your Render.com server. Make sure to select the free tier. + +![ElephantSQL screenshot showing plan configuration of Tiny Turtle (free) and name](./assets/select-plan-and-name-elephantsql.png) + +Once you've got this, you should be able to see the Database URL: + +![ElephantSQL screenshot showing that a copy icon beside the Database URL can be clicked to copy it](./assets/copy-elephantsql-url.png) + +Copy this, as you'll need it in the next lecture! diff --git a/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/assets/copy-elephantsql-url.png b/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/assets/copy-elephantsql-url.png new file mode 100644 index 00000000..edd7fb39 Binary files /dev/null and b/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/assets/copy-elephantsql-url.png differ diff --git a/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/assets/select-plan-and-name-elephantsql.png b/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/assets/select-plan-and-name-elephantsql.png new file mode 100644 index 00000000..2f1e980a Binary files /dev/null and b/docs/docs/11_deploy_to_render/04_deploy_postgresql_database/assets/select-plan-and-name-elephantsql.png differ diff --git a/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/README.md b/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/README.md new file mode 100644 index 00000000..97703a93 --- /dev/null +++ b/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/README.md @@ -0,0 +1,373 @@ +# How to use Environment Variables in Render.com + +A common way to configure applications before they start up is by using environment variables. + +We can define environment variables in our computers, and also in our servers, and of course they can be different in each. + +That's what's interesting about them: we can define an environment variable locally for our database, which may be `sqlite:///data.db`. Then in our server we can define the same variable, but with a value of the ElephantSQL Database URL. + +Since we are using SQLAlchemy in our application, it doesn't care whether it's connecting to SQLite or PostgreSQL. So all we have to do to use a different database is change the connection string. + +Let's begin by using environment variables locally. + +## Using PostgreSQL locally + +Since we are going to be using PostgreSQL when we deploy, it's a good idea to use PostgreSQL also locally. That's because SQLite and PostgreSQL behave a bit differently, so if we use SQLite locally and PostgreSQL in production, we may come across issues. + +To work with PostgreSQL locally, you can run a PostgreSQL container using Docker, you can install PostgreSQL locally, or you can create another ElephantSQL database for local development. + +I would do the last option. That way, you'll have 2 ElephantSQL databases; one for production and one for development. + +## How to use environment variables locally with our Flask app + +First let's install `psycopg2` and add it to our `requirements.txt` file: + +```text title="requirements.txt" +flask +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +# highlight-start +psycopg2 +# highlight-end +``` + +Then, let's create a new file called `.env`. In this file, we can store any environment variables we want. We can then "load" these variables when we start the app. + +```text title=".env" +DATABASE_URL=postgresql://YOUR_DEVELOPMENT_URL +``` + +:::warning +The ElephantSQL URL starts with `postgres://...`. Make sure to change it so it starts with `postgresql://...`! +::: + +With the file created, we can load it when we start our Flask app: + +```python title="app.py" +# highlight-start +import os +# highlight-end +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +# highlight-start +from dotenv import load_dotenv +# highlight-end + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + # highlight-start + load_dotenv() + # highlight-end + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + # highlight-start + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + # highlight-end + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) +``` + +Highlighted are four lines which we must change. + +1. First we `import os`. We'll need this to access environment variables. +2. Second, we import the `load_dotenv` function, which we'll need to run in order to turn the contents of the `.env` file into environment variables. +3. We actually run the `load_dotenv` function. +4. We'll use `db_url` if provided, otherwise we'll retrieve the environment variable's value. If there is no environment value, the default will be `"sqlite:///data.db"`. + +Notice that our Flask app has two ways to be configured: with the `db_url` argument, or via environment variables. You would normally use `db_url` when writing automated tests for your application. While we don't do that in this course, it's a good habit to get into! + +:::warning +Do not include your `.env` file in your GitHub repository! Add it to `.gitignore` so you don't include it accidentally. +::: + +Since we can't include `.env` in our GitHub repository, we should do something to make sure that new developers know that they should create a `.env` file when they clone the repository. + +We normally do this by creating a file called `.env.example`. This file should only contain the environment variable definitions, but not the values: + +```text title=".env.example" +DATABASE_URL= +``` + +You should add `.env.example` to your repository. + +## Changes needed to our app code for PostgreSQL + +We've been working with SQLite all this time, and PostgreSQL behaves a bit differently. There are a couple of changes we need to make to our app at this point: + +1. Make sure all foreign keys are the same data type as the primary keys they reference. +2. Change the length constraint on user passwords from `80` to `256`. + +This is because SQLite doesn't enforce either of these constraints, so although they were a problem before, we didn't know because SQLite didn't tell us about it. PostgreSQL will complain! + +### Changes to foreign keys + +The only foreign key that was mistakenly given the wrong data type was in the `TagModel`. This is the necessary change: + +```python title="models/tag.py" +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + # highlight-start + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + # highlight-end + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") +``` + +We also need to change the database migration file that creates the store ID: + +```python title="migrations/versions/07006e31e788_.py" +... + +op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + # highlight-start + sa.Column('store_id', sa.Integer(), nullable=False), + # highlight-end + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + +... +``` + +Now, let's run the migrations so that our development ElephantSQL database is created. Remember to make sure that your development ElephantSQL database is empty before starting the migrations. + +```bash +flask db upgrade +``` + +### Changes to password length + +In the `UserModel`, we'll make this change: + +```python title="models/user.py" +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + # highlight-start + password = db.Column(db.String(256), nullable=False) + # highlight-end +``` + +### Running our migration with string length changes + +Now we want to create a new migration so that our changes to the `UserModel` will be applied. But because we're changing the length of a string column, we need to first make a modification to the Alembic configuration. + +The changes we want to make are to add `compare_type=True`[^alembic_docs] in both `context.configure()` calls: + +```python title="migrations/env.py" +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + # highlight-start + compare_type=True, + # highlight-end + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + # highlight-start + compare_type=True, + # highlight-end + **current_app.extensions['migrate'].configure_args, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + +``` + +Next, let's create the new migration: + +```bash +flask db migrate +``` + +This may add a couple other data type changes, such as changing `REAL` to `Float`. This is due to how types are assigned differently between SQLite and PostgreSQL. Make sure that the password length change is in the migration: + +```python title="migrations/versions/36e961f62882_.py" +op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) +``` + +## Running database migrations in production + +So we've created our migration files and we've migrated our development database. What about our production database? + +We _could_ simply change our `.env` file, connect to production, and migrate that database. But then we'd need to remember to do that every time before we deploy, and it simply isn't feasible. + +Instead, we want a solution where the database migrations run before the app starts. That way, it will be impossible for us to forget to run the migrations when we deploy. + +To do so, we'll tell the Docker container to run the database migrations before starting the `gunicorn` process. It's more straightforward than it sounds! + +First let's write a very short bash script that runs the migrations, and then starts the gunicorn process: + +```bash title="docker-entrypoint.sh" +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" +``` + +Then, let's modify our `Dockerfile` to use that script: + +```dockerfile +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +# highlight-start +CMD ["/bin/bash", "docker-entrypoint.sh"] +# highlight-end +``` + +:::tip +If you want to run the Docker container locally with the Flask development server, our [previous instructions](/docs/deploy_to_render/docker_with_gunicorn/#run-the-docker-container-locally-with-the-flask-development-server-and-debugger) are still good. You won't be applying the migrations, but most of the time that won't be a problem. +::: + +Commit the changes, and push them to GitHub. We'll need these changes so we can use environment variables in Render.com. + +## How to add environment variables to Render.com + +Now that our Flask app is using environment variables, all we have to do is add the `DATABASE_URL` environment variable to our Render.com service, and then deploy the latest changes from our GitHub repository. + +To add environment variables in Render.com, go to the service settings and then on the left you'll see "Environment": + +![Render.com screenshot showing the button to add a environment variables](./assets/render-add-env-var.png) + +Click on "Add Environment Variable", and there put `DATABASE_URL` as the key, and your ElephantSQL Database URL as the value: + +![Render.com screenshot showing DATABASE_URL added with a pixelated value](./assets/render-database-url-env-var.png) + +:::warning +Again, make sure to use `postgresql://...` here. +::: + +Now, do another manual deploy of the latest commit. + +When this is done, your app should be saving to the ElephantSQL database, and it will apply the migrations before starting up! + +[^alembic_docs]: [Compare Types (Alembic official documentation)](https://alembic.sqlalchemy.org/en/latest/autogenerate.html#compare-types) \ No newline at end of file diff --git a/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/assets/render-add-env-var.png b/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/assets/render-add-env-var.png new file mode 100644 index 00000000..488dfcf7 Binary files /dev/null and b/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/assets/render-add-env-var.png differ diff --git a/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/assets/render-database-url-env-var.png b/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/assets/render-database-url-env-var.png new file mode 100644 index 00000000..bc8054ff Binary files /dev/null and b/docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/assets/render-database-url-env-var.png differ diff --git a/docs/docs/11_deploy_to_render/_category_.json b/docs/docs/11_deploy_to_render/_category_.json new file mode 100644 index 00000000..7ec9c5ce --- /dev/null +++ b/docs/docs/11_deploy_to_render/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Deploy REST APIs to Render", + "position": 11 +} diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js new file mode 100644 index 00000000..21ae9897 --- /dev/null +++ b/docs/docusaurus.config.js @@ -0,0 +1,116 @@ +// @ts-check +// Note: type annotations allow type checking and IDEs autocompletion + +const lightCodeTheme = require("prism-react-renderer/themes/okaidia"); +const darkCodeTheme = require("prism-react-renderer/themes/dracula"); + +/** @type {import('@docusaurus/types').Config} */ +const config = { + title: "REST APIs with Flask and Python", + tagline: + "Build and deploy REST APIs using Flask, PostgreSQL, Docker, and Celery", + url: "https://rest-apis.teclado.com", + baseUrl: "/", + onBrokenLinks: "throw", + onBrokenMarkdownLinks: "warn", + favicon: "img/favicon.ico", + organizationName: "tecladocode", // Usually your GitHub org/user name. + projectName: "rest-apis-flask-python", // Usually your repo name. + scripts: [ + { + src: "https://plausible.io/js/plausible.js", + defer: true, + "data-domain": "rest-apis-flask.teclado.com", + }, + ], + presets: [ + [ + "@docusaurus/preset-classic", + /** @type {import('@docusaurus/preset-classic').Options} */ + ({ + docs: { + sidebarPath: require.resolve("./sidebars.js"), + exclude: ["**/start/**", "**/end/**"], + // Please change this to your repo. + editUrl: "https://github.com/tecladocode/rest-apis-flask-python/", + }, + theme: { + customCss: require.resolve("./src/css/custom.css"), + }, + }), + ], + ], + + themeConfig: + /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ + ({ + navbar: { + title: "REST APIs with Flask and Python", + logo: { + alt: "Teclado Logo", + src: "img/favicon.ico", + }, + items: [ + { + type: "doc", + docId: "course_intro/intro", + position: "left", + label: "Tutorial", + }, + { + href: "https://go.tecla.do/rest-apis-sale", + label: "Get the course", + position: "right", + }, + ], + }, + footer: { + style: "dark", + links: [ + { + title: "Learn", + items: [ + { + href: "https://go.tecla.do/rest-apis-sale", + label: "Get the course", + }, + { + label: "Tutorial", + to: "/docs/course_intro/", + }, + ], + }, + { + title: "Social", + items: [ + { + label: "Discord", + href: "https://go.tecla.do/discord", + }, + { + label: "Twitter", + href: "https://twitter.com/jslvtr", + }, + ], + }, + { + title: "More", + items: [ + { + label: "GitHub", + href: "https://github.com/tecladocode/rest-apis-flask-python", + }, + ], + }, + ], + copyright: `Copyright © ${new Date().getFullYear()} Teclado Ltd. Built with Docusaurus.`, + }, + prism: { + theme: lightCodeTheme, + darkTheme: darkCodeTheme, + additionalLanguages: ["docker"], + }, + }), +}; + +module.exports = config; diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 00000000..0d7c0983 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,21950 @@ +{ + "name": "website", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "website", + "version": "0.0.0", + "dependencies": { + "@docusaurus/core": "^0.0.0-4999", + "@docusaurus/preset-classic": "^0.0.0-4999", + "@mdx-js/react": "^1.6.22", + "clsx": "^1.1.1", + "prism-react-renderer": "^1.3.1", + "react": "^17.0.2", + "react-dom": "^17.0.2" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.5.2.tgz", + "integrity": "sha512-DY0bhyczFSS1b/CqJlTE/nQRtnTAHl6IemIkBy0nEWnhDzRDdtdx4p5Uuk3vwAFxwEEgi1WqKwgSSMx6DpNL4A==", + "dependencies": { + "@algolia/autocomplete-shared": "1.5.2" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.5.2.tgz", + "integrity": "sha512-3MRYnYQFJyovANzSX2CToS6/5cfVjbLLqFsZTKcvF3abhQzxbqwwaMBlJtt620uBUOeMzhdfasKhCc40+RHiZw==", + "dependencies": { + "@algolia/autocomplete-shared": "1.5.2" + }, + "peerDependencies": { + "@algolia/client-search": "^4.9.1", + "algoliasearch": "^4.9.1" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.5.2.tgz", + "integrity": "sha512-ylQAYv5H0YKMfHgVWX0j0NmL8XBcAeeeVQUmppnnMtzDbDnca6CzhKj3Q8eF9cHCgcdTDdb5K+3aKyGWA0obug==" + }, + "node_modules/@algolia/cache-browser-local-storage": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.13.0.tgz", + "integrity": "sha512-nj1vHRZauTqP/bluwkRIgEADEimqojJgoTRCel5f6q8WCa9Y8QeI4bpDQP28FoeKnDRYa3J5CauDlN466jqRhg==", + "dependencies": { + "@algolia/cache-common": "4.13.0" + } + }, + "node_modules/@algolia/cache-common": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.13.0.tgz", + "integrity": "sha512-f9mdZjskCui/dA/fA/5a+6hZ7xnHaaZI5tM/Rw9X8rRB39SUlF/+o3P47onZ33n/AwkpSbi5QOyhs16wHd55kA==" + }, + "node_modules/@algolia/cache-in-memory": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.13.0.tgz", + "integrity": "sha512-hHdc+ahPiMM92CQMljmObE75laYzNFYLrNOu0Q3/eyvubZZRtY2SUsEEgyUEyzXruNdzrkcDxFYa7YpWBJYHAg==", + "dependencies": { + "@algolia/cache-common": "4.13.0" + } + }, + "node_modules/@algolia/client-account": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.13.0.tgz", + "integrity": "sha512-FzFqFt9b0g/LKszBDoEsW+dVBuUe1K3scp2Yf7q6pgHWM1WqyqUlARwVpLxqyc+LoyJkTxQftOKjyFUqddnPKA==", + "dependencies": { + "@algolia/client-common": "4.13.0", + "@algolia/client-search": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.13.0.tgz", + "integrity": "sha512-klmnoq2FIiiMHImkzOm+cGxqRLLu9CMHqFhbgSy9wtXZrqb8BBUIUE2VyBe7azzv1wKcxZV2RUyNOMpFqmnRZA==", + "dependencies": { + "@algolia/client-common": "4.13.0", + "@algolia/client-search": "4.13.0", + "@algolia/requester-common": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.13.0.tgz", + "integrity": "sha512-GoXfTp0kVcbgfSXOjfrxx+slSipMqGO9WnNWgeMmru5Ra09MDjrcdunsiiuzF0wua6INbIpBQFTC2Mi5lUNqGA==", + "dependencies": { + "@algolia/requester-common": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.13.0.tgz", + "integrity": "sha512-KneLz2WaehJmNfdr5yt2HQETpLaCYagRdWwIwkTqRVFCv4DxRQ2ChPVW9jeTj4YfAAhfzE6F8hn7wkQ/Jfj6ZA==", + "dependencies": { + "@algolia/client-common": "4.13.0", + "@algolia/requester-common": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.13.0.tgz", + "integrity": "sha512-blgCKYbZh1NgJWzeGf+caKE32mo3j54NprOf0LZVCubQb3Kx37tk1Hc8SDs9bCAE8hUvf3cazMPIg7wscSxspA==", + "dependencies": { + "@algolia/client-common": "4.13.0", + "@algolia/requester-common": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "node_modules/@algolia/events": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", + "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" + }, + "node_modules/@algolia/logger-common": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.13.0.tgz", + "integrity": "sha512-8yqXk7rMtmQJ9wZiHOt/6d4/JDEg5VCk83gJ39I+X/pwUPzIsbKy9QiK4uJ3aJELKyoIiDT1hpYVt+5ia+94IA==" + }, + "node_modules/@algolia/logger-console": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.13.0.tgz", + "integrity": "sha512-YepRg7w2/87L0vSXRfMND6VJ5d6699sFJBRWzZPOlek2p5fLxxK7O0VncYuc/IbVHEgeApvgXx0WgCEa38GVuQ==", + "dependencies": { + "@algolia/logger-common": "4.13.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.13.0.tgz", + "integrity": "sha512-Dj+bnoWR5MotrnjblzGKZ2kCdQi2cK/VzPURPnE616NU/il7Ypy6U6DLGZ/ZYz+tnwPa0yypNf21uqt84fOgrg==", + "dependencies": { + "@algolia/requester-common": "4.13.0" + } + }, + "node_modules/@algolia/requester-common": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.13.0.tgz", + "integrity": "sha512-BRTDj53ecK+gn7ugukDWOOcBRul59C4NblCHqj4Zm5msd5UnHFjd/sGX+RLOEoFMhetILAnmg6wMrRrQVac9vw==" + }, + "node_modules/@algolia/requester-node-http": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.13.0.tgz", + "integrity": "sha512-9b+3O4QFU4azLhGMrZAr/uZPydvzOR4aEZfSL8ZrpLZ7fbbqTO0S/5EVko+QIgglRAtVwxvf8UJ1wzTD2jvKxQ==", + "dependencies": { + "@algolia/requester-common": "4.13.0" + } + }, + "node_modules/@algolia/transporter": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.13.0.tgz", + "integrity": "sha512-8tSQYE+ykQENAdeZdofvtkOr5uJ9VcQSWgRhQ9h01AehtBIPAczk/b2CLrMsw5yQZziLs5cZ3pJ3478yI+urhA==", + "dependencies": { + "@algolia/cache-common": "4.13.0", + "@algolia/logger-common": "4.13.0", + "@algolia/requester-common": "4.13.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dependencies": { + "@babel/highlight": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.10.tgz", + "integrity": "sha512-GZt/TCsG70Ms19gfZO1tM4CVnXsPgEPBCpJu+Qz3L0LUDsY5nZqFZglIoPC1kIYOtNBZlrnFT+klg12vFGZXrw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.10.tgz", + "integrity": "sha512-liKoppandF3ZcBnIYFjfSDHZLKdLHGJRkoWtG8zQyGJBQfIYobpnVGI5+pLBNtS6psFLDzyq8+h5HiVljW9PNA==", + "dependencies": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-compilation-targets": "^7.17.10", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.10", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.10", + "@babel/types": "^7.17.10", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.10.tgz", + "integrity": "sha512-46MJZZo9y3o4kmhBVc7zW7i8dtR1oIK/sdO5NcfcZRhTGYi+KKJRtHNgsU6c4VUcJmUNV/LQdebD/9Dlv4K+Tg==", + "dependencies": { + "@babel/types": "^7.17.10", + "@jridgewell/gen-mapping": "^0.1.0", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", + "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.16.7.tgz", + "integrity": "sha512-C6FdbRaxYjwVu/geKW4ZeQ0Q31AftgRcdSnZ5/jsH6BzCJbtvXvhpfkbkThYSuutZA7nCXpPR6AD9zd1dprMkA==", + "dependencies": { + "@babel/helper-explode-assignable-expression": "^7.16.7", + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.10.tgz", + "integrity": "sha512-gh3RxjWbauw/dFiU/7whjd0qN9K6nPJMqe6+Er7rOavFh0CQUSwhAE3IcTho2rywPJFxej6TUUHDkWcYI6gGqQ==", + "dependencies": { + "@babel/compat-data": "^7.17.10", + "@babel/helper-validator-option": "^7.16.7", + "browserslist": "^4.20.2", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.9.tgz", + "integrity": "sha512-kUjip3gruz6AJKOq5i3nC6CoCEEF/oHH3cp6tOZhB+IyyyPyW0g1Gfsxn3mkk6S08pIA2y8GQh609v9G/5sHVQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-member-expression-to-functions": "^7.17.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.17.0.tgz", + "integrity": "sha512-awO2So99wG6KnlE+TPs6rn83gCz5WlEePJDTnLEqbchMVrBeAujURVphRdigsk094VhvZehFoNOihSlcBjwsXA==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "regexpu-core": "^5.0.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.1.tgz", + "integrity": "sha512-J9hGMpJQmtWmj46B3kBHmL38UhJGhYX7eqkcq+2gsstyYt341HmPeWspihX43yVRA0mS+8GGk2Gckc7bY/HCmA==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.13.0", + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/traverse": "^7.13.0", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0-0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-explode-assignable-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.16.7.tgz", + "integrity": "sha512-KyUenhWMC8VrxzkGP0Jizjo4/Zx+1nNZhgocs+gLzyZyB8SHidhoq9KK/8Ato4anhwsivfkBLftky7gvzbZMtQ==", + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", + "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", + "dependencies": { + "@babel/template": "^7.16.7", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.17.7.tgz", + "integrity": "sha512-thxXgnQ8qQ11W2wVUObIqDL4p148VMxkt5T/qpN5k2fboRyzFGFmKsTGViquyM5QHKUy48OZoca8kw4ajaDPyw==", + "dependencies": { + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", + "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", + "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.3", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz", + "integrity": "sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==", + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.16.8.tgz", + "integrity": "sha512-fm0gH7Flb8H51LqJHy3HJ3wnE1+qtYR2A99K06ahwrawLdOFsCEWjZOrYricXJHoPSudNKxrMBUPEIPxiIIvBw==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-wrap-function": "^7.16.8", + "@babel/types": "^7.16.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz", + "integrity": "sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-member-expression-to-functions": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/traverse": "^7.16.7", + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", + "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", + "dependencies": { + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.16.0.tgz", + "integrity": "sha512-+il1gTy0oHwUsBQZyJvukbB4vPMdcYBrFHa0Uc4AizLxbq6BOYC51Rv4tWocX9BLBDLZ4kc6qUFpQ6HRgL+3zw==", + "dependencies": { + "@babel/types": "^7.16.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.16.8.tgz", + "integrity": "sha512-8RpyRVIAW1RcDDGTA+GpPAwV22wXCfKOoM9bet6TLkGIFTkRQSkH1nMQ5Yet4MpoXe1ZwHPVtNasc2w0uZMqnw==", + "dependencies": { + "@babel/helper-function-name": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.16.8", + "@babel/types": "^7.16.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", + "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", + "dependencies": { + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", + "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.10.tgz", + "integrity": "sha512-n2Q6i+fnJqzOaq2VkdXxy2TCPCWQZHiCo0XqmrCvDWcZQKRyZzYi4Z0yxlBuN0w+r2ZHmre+Q087DSrw3pbJDQ==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.16.7.tgz", + "integrity": "sha512-anv/DObl7waiGEnC24O9zqL0pSuI9hljihqiDuFHC8d7/bjr/4RLGPWuc8rYOff/QPzbEPSkzG8wGG9aDuhHRg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.16.7.tgz", + "integrity": "sha512-di8vUHRdf+4aJ7ltXhaDbPoszdkh59AQtJM5soLsuHpQJdFQZOA4uGj0V2u/CZ8bJ/u8ULDL5yq6FO/bCXnKHw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-proposal-async-generator-functions": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.16.8.tgz", + "integrity": "sha512-71YHIvMuiuqWJQkebWJtdhQTfd4Q4mF76q2IX37uZPkG9+olBxsX+rH1vkhFto4UeJZ9dPY2s+mDvhDm1u2BGQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-remap-async-to-generator": "^7.16.8", + "@babel/plugin-syntax-async-generators": "^7.8.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.16.7.tgz", + "integrity": "sha512-IobU0Xme31ewjYOShSIqd/ZGM/r/cuOz2z0MDbNrhF5FW+ZVgi0f2lyeoj9KFPDOAqsYxmLWZte1WOwlvY9aww==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-class-static-block": { + "version": "7.17.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.17.6.tgz", + "integrity": "sha512-X/tididvL2zbs7jZCeeRJ8167U/+Ac135AM6jCAx6gYXDUviZV5Ku9UDvWS2NCuWlFjIRXklYhwo6HhAC7ETnA==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.17.6", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-proposal-dynamic-import": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.16.7.tgz", + "integrity": "sha512-I8SW9Ho3/8DRSdmDdH3gORdyUuYnk1m4cMxUAdu5oy4n3OfN8flDEH+d60iG7dUfi0KkYwSvoalHzzdRzpWHTg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-namespace-from": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.16.7.tgz", + "integrity": "sha512-ZxdtqDXLRGBL64ocZcs7ovt71L3jhC1RGSyR996svrCi3PYqHNkb3SwPJCs8RIzD86s+WPpt2S73+EHCGO+NUA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-json-strings": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.16.7.tgz", + "integrity": "sha512-lNZ3EEggsGY78JavgbHsK9u5P3pQaW7k4axlgFLYkMd7UBsiNahCITShLjNQschPyjtO6dADrL24757IdhBrsQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.16.7.tgz", + "integrity": "sha512-K3XzyZJGQCr00+EtYtrDjmwX7o7PLK6U9bi1nCwkQioRFVUv6dJoxbQjtWVtP+bCPy82bONBKG8NPyQ4+i6yjg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.16.7.tgz", + "integrity": "sha512-aUOrYU3EVtjf62jQrCj63pYZ7k6vns2h/DQvHPWGmsJRYzWXZ6/AsfgpiRy6XiuIDADhJzP2Q9MwSMKauBQ+UQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-numeric-separator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.16.7.tgz", + "integrity": "sha512-vQgPMknOIgiuVqbokToyXbkY/OmmjAzr/0lhSIbG/KmnzXPGwW/AdhdKpi+O4X/VkWiWjnkKOBiqJrTaC98VKw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.17.3.tgz", + "integrity": "sha512-yuL5iQA/TbZn+RGAfxQXfi7CNLmKi1f8zInn4IgobuCWcAb7i+zj4TYzQ9l8cEzVyJ89PDGuqxK1xZpUDISesw==", + "dependencies": { + "@babel/compat-data": "^7.17.0", + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-catch-binding": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.16.7.tgz", + "integrity": "sha512-eMOH/L4OvWSZAE1VkHbr1vckLG1WUcHGJSLqqQwl2GaUqG6QjddvrOaTUMNYiv77H5IKPMZ9U9P7EaHwvAShfA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.16.7.tgz", + "integrity": "sha512-eC3xy+ZrUcBtP7x+sq62Q/HYd674pPTb/77XZMb5wbDPGWIdUbSr4Agr052+zaUPSb+gGRnjxXfKFvx5iMJ+DA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.16.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.16.11.tgz", + "integrity": "sha512-F/2uAkPlXDr8+BHpZvo19w3hLFKge+k75XUprE6jaqKxjGkSYcK+4c+bup5PdW/7W/Rpjwql7FTVEDW+fRAQsw==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.16.10", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.16.7.tgz", + "integrity": "sha512-rMQkjcOFbm+ufe3bTZLyOfsOUOxyvLXZJCTARhJr+8UMSoZmqTe1K1BgkFcrW37rAchWg57yI69ORxiWvUINuQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-unicode-property-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.16.7.tgz", + "integrity": "sha512-QRK0YI/40VLhNVGIjRNAAQkEHws0cswSdFFjpFyt943YmJIU1da9uW63Iu6NFV6CxTZW5eTDCrwZUstBWgp/Rg==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", + "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.17.10.tgz", + "integrity": "sha512-xJefea1DWXW09pW4Tm9bjwVlPDyYA2it3fWlmEjpYz6alPvTUjL0EOzNzI/FEOyI3r4/J7uVH5UqKgl1TQ5hqQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.16.7.tgz", + "integrity": "sha512-9ffkFFMbvzTvv+7dTp/66xvZAWASuPD5Tl9LK3Z9vhOmANo6j94rik+5YMBt4CwHVMWLWpMsriIc2zsa3WW3xQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.16.8.tgz", + "integrity": "sha512-MtmUmTJQHCnyJVrScNzNlofQJ3dLFuobYn3mwOTKHnSCMtbNsqvF71GQmJfFjdrXSsAA7iysFmYWw4bXZ20hOg==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-remap-async-to-generator": "^7.16.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.16.7.tgz", + "integrity": "sha512-JUuzlzmF40Z9cXyytcbZEZKckgrQzChbQJw/5PuEHYeqzCsvebDx0K0jWnIIVcmmDOAVctCgnYs0pMcrYj2zJg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.16.7.tgz", + "integrity": "sha512-ObZev2nxVAYA4bhyusELdo9hb3H+A56bxH3FZMbEImZFiEDYVHXQSJ1hQKFlDnlt8G9bBrCZ5ZpURZUrV4G5qQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.16.7.tgz", + "integrity": "sha512-WY7og38SFAGYRe64BrjKf8OrE6ulEHtr5jEYaZMwox9KebgqPi67Zqz8K53EKk1fFEJgm96r32rkKZ3qA2nCWQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.16.7.tgz", + "integrity": "sha512-gN72G9bcmenVILj//sv1zLNaPyYcOzUho2lIJBMh/iakJ9ygCo/hEF9cpGb61SCMEDxbbyBoVQxrt+bWKu5KGw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.17.7.tgz", + "integrity": "sha512-XVh0r5yq9sLR4vZ6eVZe8FKfIcSgaTBxVBRSYokRj2qksf6QerYnTxz9/GTuKTH/n/HwLP7t6gtlybHetJ/6hQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.16.7.tgz", + "integrity": "sha512-Lyttaao2SjZF6Pf4vk1dVKv8YypMpomAbygW+mU5cYP3S5cWTfCJjG8xV6CFdzGFlfWK81IjL9viiTvpb6G7gQ==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.16.7.tgz", + "integrity": "sha512-03DvpbRfvWIXyK0/6QiR1KMTWeT6OcQ7tbhjrXyFS02kjuX/mu5Bvnh5SDSWHxyawit2g5aWhKwI86EE7GUnTw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.16.7.tgz", + "integrity": "sha512-8UYLSlyLgRixQvlYH3J2ekXFHDFLQutdy7FfFAMm3CPZ6q9wHCwnUyiXpQCe3gVVnQlHc5nsuiEVziteRNTXEA==", + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.16.7.tgz", + "integrity": "sha512-/QZm9W92Ptpw7sjI9Nx1mbcsWz33+l8kuMIQnDwgQBG5s3fAfQvkRjQ7NqXhtNcKOnPkdICmUHyCaWW06HCsqg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.16.7.tgz", + "integrity": "sha512-SU/C68YVwTRxqWj5kgsbKINakGag0KTgq9f2iZEXdStoAbOzLHEBRYzImmA6yFo8YZhJVflvXmIHUO7GWHmxxA==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.16.7.tgz", + "integrity": "sha512-6tH8RTpTWI0s2sV6uq3e/C9wPo4PTqqZps4uF0kzQ9/xPLFQtipynvmT1g/dOfEJ+0EQsHhkQ/zyRId8J2b8zQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.16.7.tgz", + "integrity": "sha512-mBruRMbktKQwbxaJof32LT9KLy2f3gH+27a5XSuXo6h7R3vqltl0PgZ80C8ZMKw98Bf8bqt6BEVi3svOh2PzMw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.16.7.tgz", + "integrity": "sha512-KaaEtgBL7FKYwjJ/teH63oAmE3lP34N3kshz8mm4VMAw7U3PxjVwwUmxEFksbgsNUaO3wId9R2AVQYSEGRa2+g==", + "dependencies": { + "@babel/helper-module-transforms": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd/node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.17.9.tgz", + "integrity": "sha512-2TBFd/r2I6VlYn0YRTz2JdazS+FoUuQ2rIFHoAxtyP/0G3D82SBLaRq9rnUkpqlLg03Byfl/+M32mpxjO6KaPw==", + "dependencies": { + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.17.8.tgz", + "integrity": "sha512-39reIkMTUVagzgA5x88zDYXPCMT6lcaRKs1+S9K6NKBPErbgO/w/kP8GlNQTC87b412ZTlmNgr3k2JrWgHH+Bw==", + "dependencies": { + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs/node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.16.7.tgz", + "integrity": "sha512-EMh7uolsC8O4xhudF2F6wedbSHm1HHZ0C6aJ7K67zcDNidMzVcxWdGr+htW9n21klm+bOn+Rx4CBsAntZd3rEQ==", + "dependencies": { + "@babel/helper-module-transforms": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.17.10.tgz", + "integrity": "sha512-v54O6yLaJySCs6mGzaVOUw9T967GnH38T6CQSAtnzdNPwu84l2qAjssKzo/WSO8Yi7NF+7ekm5cVbF/5qiIgNA==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.16.7.tgz", + "integrity": "sha512-xiLDzWNMfKoGOpc6t3U+etCE2yRnn3SM09BXqWPIZOBpL2gvVrBWUKnsJx0K/ADi5F5YC5f8APFfWrz25TdlGg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.16.7.tgz", + "integrity": "sha512-14J1feiQVWaGvRxj2WjyMuXS2jsBkgB3MdSN5HuC2G5nRspa5RK9COcs82Pwy5BuGcjb+fYaUj94mYcOj7rCvw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.16.7.tgz", + "integrity": "sha512-AT3MufQ7zZEhU2hwOA11axBnExW0Lszu4RL/tAlUJBuNoRak+wehQW8h6KcXOcgjY42fHtDxswuMhMjFEuv/aw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.16.7.tgz", + "integrity": "sha512-z4FGr9NMGdoIl1RqavCqGG+ZuYjfZ/hkCIeuH6Do7tXmSm0ls11nYVSJqFEUOSJbDab5wC6lRE/w6YjVcr6Hqw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.17.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.17.6.tgz", + "integrity": "sha512-OBv9VkyyKtsHZiHLoSfCn+h6yU7YKX8nrs32xUmOa1SRSk+t03FosB6fBZ0Yz4BpD1WV7l73Nsad+2Tz7APpqw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.16.7.tgz", + "integrity": "sha512-qgIg8BcZgd0G/Cz916D5+9kqX0c7nPZyXaP8R2tLNN5tkyIZdG5fEwBrxwplzSnjC1jvQmyMNVwUCZPcbGY7Pg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", + "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-jsx": "^7.16.7", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", + "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.16.7.tgz", + "integrity": "sha512-hs71ToC97k3QWxswh2ElzMFABXHvGiJ01IB1TbYQDGeWRKWz/MPUTh5jGExdHvosYKpnJW5Pm3S4+TA3FyX+GA==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.17.9.tgz", + "integrity": "sha512-Lc2TfbxR1HOyn/c6b4Y/b6NHoTb67n/IoWLxTu4kC7h4KQnWlhCq2S8Tx0t2SVvv5Uu87Hs+6JEJ5kt2tYGylQ==", + "dependencies": { + "regenerator-transform": "^0.15.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.16.7.tgz", + "integrity": "sha512-KQzzDnZ9hWQBjwi5lpY5v9shmm6IVG0U9pB18zvMu2i4H90xpT4gmqwPYsn8rObiadYe2M0gmgsiOIF5A/2rtg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.17.10.tgz", + "integrity": "sha512-6jrMilUAJhktTr56kACL8LnWC5hx3Lf27BS0R0DSyW/OoJfb/iTHeE96V3b1dgKG3FSFdd/0culnYWMkjcKCig==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "babel-plugin-polyfill-corejs2": "^0.3.0", + "babel-plugin-polyfill-corejs3": "^0.5.0", + "babel-plugin-polyfill-regenerator": "^0.3.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.16.7.tgz", + "integrity": "sha512-hah2+FEnoRoATdIb05IOXf+4GzXYTq75TVhIn1PewihbpyrNWUt2JbudKQOETWw6QpLe+AIUpJ5MVLYTQbeeUg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.16.7.tgz", + "integrity": "sha512-+pjJpgAngb53L0iaA5gU/1MLXJIfXcYepLgXB3esVRf4fqmj8f2cxM3/FKaHsZms08hFQJkFccEWuIpm429TXg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.16.7.tgz", + "integrity": "sha512-NJa0Bd/87QV5NZZzTuZG5BPJjLYadeSZ9fO6oOUoL4iQx+9EEuw/eEM92SrsT19Yc2jgB1u1hsjqDtH02c3Drw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.16.7.tgz", + "integrity": "sha512-VwbkDDUeenlIjmfNeDX/V0aWrQH2QiVyJtwymVQSzItFDTpxfyJh3EVaQiS0rIN/CqbLGr0VcGmuwyTdZtdIsA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.16.7.tgz", + "integrity": "sha512-p2rOixCKRJzpg9JB4gjnG4gjWkWa89ZoYUnl9snJ1cWIcTH/hvxZqfO+WjG6T8DRBpctEol5jw1O5rA8gkCokQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", + "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-typescript": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz", + "integrity": "sha512-TAV5IGahIz3yZ9/Hfv35TV2xEm+kaBDaZQCn2S/hG9/CZ0DktxJv9eKfPc7yYCvOYR4JGx1h8C+jcSOvgaaI/Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.16.7.tgz", + "integrity": "sha512-oC5tYYKw56HO75KZVLQ+R/Nl3Hro9kf8iG0hXoaHP7tjAyCpvqBiSNe6vGrZni1Z6MggmUOC6A7VP7AVmw225Q==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.17.10.tgz", + "integrity": "sha512-YNgyBHZQpeoBSRBg0xixsZzfT58Ze1iZrajvv0lJc70qDDGuGfonEnMGfWeSY0mQ3JTuCWFbMkzFRVafOyJx4g==", + "dependencies": { + "@babel/compat-data": "^7.17.10", + "@babel/helper-compilation-targets": "^7.17.10", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.16.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.16.7", + "@babel/plugin-proposal-async-generator-functions": "^7.16.8", + "@babel/plugin-proposal-class-properties": "^7.16.7", + "@babel/plugin-proposal-class-static-block": "^7.17.6", + "@babel/plugin-proposal-dynamic-import": "^7.16.7", + "@babel/plugin-proposal-export-namespace-from": "^7.16.7", + "@babel/plugin-proposal-json-strings": "^7.16.7", + "@babel/plugin-proposal-logical-assignment-operators": "^7.16.7", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7", + "@babel/plugin-proposal-numeric-separator": "^7.16.7", + "@babel/plugin-proposal-object-rest-spread": "^7.17.3", + "@babel/plugin-proposal-optional-catch-binding": "^7.16.7", + "@babel/plugin-proposal-optional-chaining": "^7.16.7", + "@babel/plugin-proposal-private-methods": "^7.16.11", + "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "@babel/plugin-proposal-unicode-property-regex": "^7.16.7", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.16.7", + "@babel/plugin-transform-async-to-generator": "^7.16.8", + "@babel/plugin-transform-block-scoped-functions": "^7.16.7", + "@babel/plugin-transform-block-scoping": "^7.16.7", + "@babel/plugin-transform-classes": "^7.16.7", + "@babel/plugin-transform-computed-properties": "^7.16.7", + "@babel/plugin-transform-destructuring": "^7.17.7", + "@babel/plugin-transform-dotall-regex": "^7.16.7", + "@babel/plugin-transform-duplicate-keys": "^7.16.7", + "@babel/plugin-transform-exponentiation-operator": "^7.16.7", + "@babel/plugin-transform-for-of": "^7.16.7", + "@babel/plugin-transform-function-name": "^7.16.7", + "@babel/plugin-transform-literals": "^7.16.7", + "@babel/plugin-transform-member-expression-literals": "^7.16.7", + "@babel/plugin-transform-modules-amd": "^7.16.7", + "@babel/plugin-transform-modules-commonjs": "^7.17.9", + "@babel/plugin-transform-modules-systemjs": "^7.17.8", + "@babel/plugin-transform-modules-umd": "^7.16.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.17.10", + "@babel/plugin-transform-new-target": "^7.16.7", + "@babel/plugin-transform-object-super": "^7.16.7", + "@babel/plugin-transform-parameters": "^7.16.7", + "@babel/plugin-transform-property-literals": "^7.16.7", + "@babel/plugin-transform-regenerator": "^7.17.9", + "@babel/plugin-transform-reserved-words": "^7.16.7", + "@babel/plugin-transform-shorthand-properties": "^7.16.7", + "@babel/plugin-transform-spread": "^7.16.7", + "@babel/plugin-transform-sticky-regex": "^7.16.7", + "@babel/plugin-transform-template-literals": "^7.16.7", + "@babel/plugin-transform-typeof-symbol": "^7.16.7", + "@babel/plugin-transform-unicode-escapes": "^7.16.7", + "@babel/plugin-transform-unicode-regex": "^7.16.7", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.17.10", + "babel-plugin-polyfill-corejs2": "^0.3.0", + "babel-plugin-polyfill-corejs3": "^0.5.0", + "babel-plugin-polyfill-regenerator": "^0.3.0", + "core-js-compat": "^3.22.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.16.7.tgz", + "integrity": "sha512-fWpyI8UM/HE6DfPBzD8LnhQ/OcH8AgTaqcqP2nGOXEUV+VKBR5JRN9hCk9ai+zQQ57vtm9oWeXguBCPNUjytgA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-react-display-name": "^7.16.7", + "@babel/plugin-transform-react-jsx": "^7.16.7", + "@babel/plugin-transform-react-jsx-development": "^7.16.7", + "@babel/plugin-transform-react-pure-annotations": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", + "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-typescript": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", + "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", + "dependencies": { + "regenerator-runtime": "^0.13.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.9.tgz", + "integrity": "sha512-WxYHHUWF2uZ7Hp1K+D1xQgbgkGUfA+5UPOegEXGt2Y5SMog/rYCVaifLZDbw8UkNXozEqqrZTy6bglL7xTaCOw==", + "dependencies": { + "core-js-pure": "^3.20.2", + "regenerator-runtime": "^0.13.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.10.tgz", + "integrity": "sha512-VmbrTHQteIdUUQNTb+zE12SHS/xQVIShmBPhlNP12hD5poF2pbITW1Z4172d03HegaQWhLffdkRJYtAzp0AGcw==", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.10", + "@babel/types": "^7.17.10", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.10.tgz", + "integrity": "sha512-9O26jG0mBYfGkUYCYZRnBwbVLd1UZOICEr2Em6InB6jVfsAv1GKgwXHmrSg+WFWDmeKTA6vyTZiN8tCSM5Oo3A==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@docsearch/css": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.0.0.tgz", + "integrity": "sha512-1kkV7tkAsiuEd0shunYRByKJe3xQDG2q7wYg24SOw1nV9/2lwEd4WrUYRJC/ukGTl2/kHeFxsaUvtiOy0y6fFA==" + }, + "node_modules/@docsearch/react": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.0.0.tgz", + "integrity": "sha512-yhMacqS6TVQYoBh/o603zszIb5Bl8MIXuOc6Vy617I74pirisDzzcNh0NEaYQt50fVVR3khUbeEhUEWEWipESg==", + "dependencies": { + "@algolia/autocomplete-core": "1.5.2", + "@algolia/autocomplete-preset-algolia": "1.5.2", + "@docsearch/css": "3.0.0", + "algoliasearch": "^4.0.0" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 18.0.0", + "react": ">= 16.8.0 < 18.0.0", + "react-dom": ">= 16.8.0 < 18.0.0" + } + }, + "node_modules/@docusaurus/core": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-0.0.0-4999.tgz", + "integrity": "sha512-LoZAyzKIIKRux9axoI674ZFv3aPZSHzYcTGWp6+Mn9OYNLgtiZyNdqqvqvEl2mEe9o4R0umu+13mnxnNT9TyfQ==", + "dependencies": { + "@babel/core": "^7.17.10", + "@babel/generator": "^7.17.10", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.17.10", + "@babel/preset-env": "^7.17.10", + "@babel/preset-react": "^7.16.7", + "@babel/preset-typescript": "^7.16.7", + "@babel/runtime": "^7.17.9", + "@babel/runtime-corejs3": "^7.17.9", + "@babel/traverse": "^7.17.10", + "@docusaurus/cssnano-preset": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/mdx-loader": "0.0.0-4999", + "@docusaurus/react-loadable": "5.5.2", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-common": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "@slorber/static-site-generator-webpack-plugin": "^4.0.4", + "@svgr/webpack": "^6.2.1", + "autoprefixer": "^10.4.7", + "babel-loader": "^8.2.5", + "babel-plugin-dynamic-import-node": "2.3.0", + "boxen": "^6.2.1", + "chokidar": "^3.5.3", + "clean-css": "^5.3.0", + "cli-table3": "^0.6.2", + "combine-promises": "^1.1.0", + "commander": "^5.1.0", + "copy-webpack-plugin": "^10.2.4", + "core-js": "^3.22.5", + "css-loader": "^6.7.1", + "css-minimizer-webpack-plugin": "^3.4.1", + "cssnano": "^5.1.7", + "del": "^6.0.0", + "detect-port": "^1.3.0", + "escape-html": "^1.0.3", + "eta": "^1.12.3", + "file-loader": "^6.2.0", + "fs-extra": "^10.1.0", + "html-minifier-terser": "^6.1.0", + "html-tags": "^3.2.0", + "html-webpack-plugin": "^5.5.0", + "import-fresh": "^3.3.0", + "leven": "^3.1.0", + "lodash": "^4.17.21", + "mini-css-extract-plugin": "^2.6.0", + "postcss": "^8.4.13", + "postcss-loader": "^6.2.1", + "prompts": "^2.4.2", + "react-dev-utils": "^12.0.1", + "react-helmet-async": "^1.3.0", + "react-loadable": "npm:@docusaurus/react-loadable@5.5.2", + "react-loadable-ssr-addon-v5-slorber": "^1.0.1", + "react-router": "^5.2.0", + "react-router-config": "^5.1.1", + "react-router-dom": "^5.2.0", + "remark-admonitions": "^1.2.1", + "rtl-detect": "^1.0.4", + "semver": "^7.3.7", + "serve-handler": "^6.1.3", + "shelljs": "^0.8.5", + "terser-webpack-plugin": "^5.3.1", + "tslib": "^2.4.0", + "update-notifier": "^5.1.0", + "url-loader": "^4.1.1", + "wait-on": "^6.0.1", + "webpack": "^5.72.1", + "webpack-bundle-analyzer": "^4.5.0", + "webpack-dev-server": "^4.9.0", + "webpack-merge": "^5.8.0", + "webpackbar": "^5.0.2" + }, + "bin": { + "docusaurus": "bin/docusaurus.mjs" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/cssnano-preset": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-0.0.0-4999.tgz", + "integrity": "sha512-rLnqtmSs7MoRYOO1+QnoB0tbpKtX3fA5oVHxdGrOak+F8JgVLbzgBHhvtr1dYN6dmbZuqSmBlbuScavAWeiLlw==", + "dependencies": { + "cssnano-preset-advanced": "^5.3.3", + "postcss": "^8.4.13", + "postcss-sort-media-queries": "^4.2.1" + } + }, + "node_modules/@docusaurus/logger": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-0.0.0-4999.tgz", + "integrity": "sha512-tpFzK0O2mRTER8Jmi6caI0R5sMXnTdj7e/Lo+vxclv8bWcE2+CFnZTIXNn/+n1euQd4f6vBwubfRnNJyP9uQ9g==", + "dependencies": { + "chalk": "^4.1.2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@docusaurus/logger/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@docusaurus/logger/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@docusaurus/logger/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@docusaurus/logger/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@docusaurus/logger/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@docusaurus/logger/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@docusaurus/mdx-loader": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-0.0.0-4999.tgz", + "integrity": "sha512-Emhdmf20Qi633vUoe2h3g5qFffvuYAahlYEaW6dxMF6T5NPw2Jr41oWdbjsPhSKOX6VRtDX6RmQUaUVVQWzc3A==", + "dependencies": { + "@babel/parser": "^7.17.10", + "@babel/traverse": "^7.17.10", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@mdx-js/mdx": "^1.6.22", + "escape-html": "^1.0.3", + "file-loader": "^6.2.0", + "fs-extra": "^10.1.0", + "image-size": "^1.0.1", + "mdast-util-to-string": "^2.0.0", + "remark-emoji": "^2.2.0", + "stringify-object": "^3.3.0", + "tslib": "^2.4.0", + "unist-util-visit": "^2.0.3", + "url-loader": "^4.1.1", + "webpack": "^5.72.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/module-type-aliases": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-0.0.0-4999.tgz", + "integrity": "sha512-mR2qwLff5qv9Nv47XmqnpMuHE9WwTGfeGpYNzLG5j3S4zLWJjAFzWPhudqGe0FCjySkAHHFf2EwikRUnHsJ0xQ==", + "dependencies": { + "@docusaurus/types": "0.0.0-4999", + "@types/react": "*", + "@types/react-router-config": "*", + "@types/react-router-dom": "*", + "react-helmet-async": "*" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@docusaurus/plugin-content-blog": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-0.0.0-4999.tgz", + "integrity": "sha512-1jv2HnBWxc/wp35A7H2BGTzUCmwf9iSxSioNvQe7TS0UpRo2IiO2CRC8a0GKx37gKJBWUl3JBvOYwBW6ZS3yDg==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/mdx-loader": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-common": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "cheerio": "^1.0.0-rc.10", + "feed": "^4.2.2", + "fs-extra": "^10.1.0", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "remark-admonitions": "^1.2.1", + "tslib": "^2.4.0", + "unist-util-visit": "^2.0.3", + "utility-types": "^3.10.0", + "webpack": "^5.72.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-docs": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-0.0.0-4999.tgz", + "integrity": "sha512-cv3fXhal2SjUNzHWeWiH6JGczdqKHLZ4ZFpFjNV7IYfoHAvC8pU31RfdLg7X4OxUG32lFDjQk132l/aTHEdmTQ==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/mdx-loader": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "combine-promises": "^1.1.0", + "fs-extra": "^10.1.0", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "remark-admonitions": "^1.2.1", + "tslib": "^2.4.0", + "utility-types": "^3.10.0", + "webpack": "^5.72.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-pages": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-0.0.0-4999.tgz", + "integrity": "sha512-RVPm2pj5HPNyfUO3FFW3Uazrhz8xEiDUcxWkr0FtNh8oG1alTW6KAPotmO9j4t/NAw+fEb4PzyPumfvYoH2H3w==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/mdx-loader": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "fs-extra": "^10.1.0", + "remark-admonitions": "^1.2.1", + "tslib": "^2.4.0", + "webpack": "^5.72.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/plugin-debug": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-0.0.0-4999.tgz", + "integrity": "sha512-CDjqbo6nTlc7GoJn5y29iTLbIVREZh6CdXlKZGCb31TDAYOnazugzY73jv7avxBGGJRQDaHs6zKJhQj26+f9QA==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "fs-extra": "^10.1.0", + "react-json-view": "^1.21.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-analytics": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-0.0.0-4999.tgz", + "integrity": "sha512-YnLbqvebBSTBaBi3zqAR2hmp4thFm0yL84FQS8uMq5K+BKsLDuiA5kzgneldfJ4BEz8L/AFWU+aXFN3Ug5vt9w==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-gtag": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-0.0.0-4999.tgz", + "integrity": "sha512-AP9ioYmaSSLihOPWE5tRsmbL1NY33h9SrN1HQyUqhYm4wV2LTo31+qB+u8kxzts7/lqbcQJpazFc/PtQhj976w==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/plugin-sitemap": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-0.0.0-4999.tgz", + "integrity": "sha512-JyUNqOAPNyJjxLroE3QJQZL0045vKJz4G50MUUGBhgDlwEk+B8K0CTmcyef5jjFuRPSFRtJ23j3N1wzOAWfYOw==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-common": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "fs-extra": "^10.1.0", + "sitemap": "^7.1.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/preset-classic": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-0.0.0-4999.tgz", + "integrity": "sha512-adpxuIUW+lsXsNqv69ISJoEoW1B/A0rqavTL+zKfeKHB8LNdFINOPspwE9oJAaIAQuDB/vTjts6wQlyED/EKZg==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/plugin-content-blog": "0.0.0-4999", + "@docusaurus/plugin-content-docs": "0.0.0-4999", + "@docusaurus/plugin-content-pages": "0.0.0-4999", + "@docusaurus/plugin-debug": "0.0.0-4999", + "@docusaurus/plugin-google-analytics": "0.0.0-4999", + "@docusaurus/plugin-google-gtag": "0.0.0-4999", + "@docusaurus/plugin-sitemap": "0.0.0-4999", + "@docusaurus/theme-classic": "0.0.0-4999", + "@docusaurus/theme-common": "0.0.0-4999", + "@docusaurus/theme-search-algolia": "0.0.0-4999" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/react-loadable": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz", + "integrity": "sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==", + "dependencies": { + "@types/react": "*", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@docusaurus/theme-classic": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-0.0.0-4999.tgz", + "integrity": "sha512-lX4bg8pAJfXzkaEclf5XCVV3/yIZaQy7v6Ow9SxXdxQHLU2gLmOoIdoe5g28YAIMHr7JnJu1J0SFO6cD6OTwsg==", + "dependencies": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/plugin-content-blog": "0.0.0-4999", + "@docusaurus/plugin-content-docs": "0.0.0-4999", + "@docusaurus/plugin-content-pages": "0.0.0-4999", + "@docusaurus/theme-common": "0.0.0-4999", + "@docusaurus/theme-translations": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-common": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "@mdx-js/react": "^1.6.22", + "clsx": "^1.1.1", + "copy-text-to-clipboard": "^3.0.1", + "infima": "0.2.0-alpha.39", + "lodash": "^4.17.21", + "nprogress": "^0.2.0", + "postcss": "^8.4.13", + "prism-react-renderer": "^1.3.1", + "prismjs": "^1.28.0", + "react-router-dom": "^5.2.0", + "rtlcss": "^3.5.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/theme-common": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-0.0.0-4999.tgz", + "integrity": "sha512-KsmrS0NxaZkeMBuHe1gZ47KxW/CEuZtN1x1T9Q+BRbCbKcaW26jv2pzLOy80hRtDg5QA6IcxsH52PzJE2aGN7A==", + "dependencies": { + "@docusaurus/module-type-aliases": "0.0.0-4999", + "@docusaurus/plugin-content-blog": "0.0.0-4999", + "@docusaurus/plugin-content-docs": "0.0.0-4999", + "@docusaurus/plugin-content-pages": "0.0.0-4999", + "clsx": "^1.1.1", + "parse-numeric-range": "^1.3.0", + "prism-react-renderer": "^1.3.1", + "tslib": "^2.4.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/theme-search-algolia": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-0.0.0-4999.tgz", + "integrity": "sha512-JQ8pLgKMLCEG8tcbK2L2EC7xOuozchxQTlOc1YAJ4olSBbJt06r+8gBwwJ6TQHA1jzc3VYnVLtfU4HLDXA0ZZg==", + "dependencies": { + "@docsearch/react": "^3.0.0", + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/plugin-content-docs": "0.0.0-4999", + "@docusaurus/theme-common": "0.0.0-4999", + "@docusaurus/theme-translations": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "algoliasearch": "^4.13.0", + "algoliasearch-helper": "^3.8.2", + "clsx": "^1.1.1", + "eta": "^1.12.3", + "fs-extra": "^10.1.0", + "lodash": "^4.17.21", + "tslib": "^2.4.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/theme-translations": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-0.0.0-4999.tgz", + "integrity": "sha512-gOwkq+UZGzdJHuNbMRQTt2qYWWyWBH6jIVNe4wlzGoNZo6fWza4qpgHJ5QV191/wBSi1gOQ0Nnzo90Pw2n0ySA==", + "dependencies": { + "fs-extra": "^10.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@docusaurus/types": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-0.0.0-4999.tgz", + "integrity": "sha512-hwoTiIVaHXTeqRlh1dOmCFAO0K193zMNt4WsVqiO6qHkNchdHvdJ9ssumNKF5PwmxD5qHRY9OwlC/LDoa9YPlw==", + "dependencies": { + "commander": "^5.1.0", + "history": "^4.9.0", + "joi": "^17.6.0", + "react-helmet-async": "^1.3.0", + "utility-types": "^3.10.0", + "webpack": "^5.72.1", + "webpack-merge": "^5.8.0" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/utils": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-0.0.0-4999.tgz", + "integrity": "sha512-xepIi3L2W7oYTeKpyLaFxih5dcjQqFJ3aTM8IkMmfqc3354k8NAEh08SZsII/5EsnCEMs9L7EfSJToIrrLI2Cw==", + "dependencies": { + "@docusaurus/logger": "0.0.0-4999", + "@svgr/webpack": "^6.2.1", + "file-loader": "^6.2.0", + "fs-extra": "^10.1.0", + "github-slugger": "^1.4.0", + "globby": "^11.1.0", + "gray-matter": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "micromatch": "^4.0.5", + "resolve-pathname": "^3.0.0", + "shelljs": "^0.8.5", + "tslib": "^2.4.0", + "url-loader": "^4.1.1", + "webpack": "^5.72.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@docusaurus/utils-common": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-0.0.0-4999.tgz", + "integrity": "sha512-QDQLKb9Jbt3GB2vZ6ymwD+3xDbqU3P2gxilaeUbWB1mw1CCtBziZKLDo4syCs2i3f8l+tqEft+e2KPkpuq05TQ==", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@docusaurus/utils-validation": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-0.0.0-4999.tgz", + "integrity": "sha512-2JSqhk3RkJzK+4rsAusZlFtZKc1dAVPUWmSvs9AyPamnGdbEMiawj0vXJji/eIkzVgUMy+qYLT+THLa3DLUCsA==", + "dependencies": { + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "joi": "^17.6.0", + "js-yaml": "^4.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", + "integrity": "sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", + "dependencies": { + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", + "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.0.tgz", + "integrity": "sha512-SfJxIxNVYLTsKwzB3MoOQ1yxf4w/E6MdkvTgrgAt1bfxjSrLUoHMKrDOykwN14q65waezZIdqDneUIPh4/sKxg==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz", + "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" + }, + "node_modules/@mdx-js/mdx": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", + "integrity": "sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA==", + "dependencies": { + "@babel/core": "7.12.9", + "@babel/plugin-syntax-jsx": "7.12.1", + "@babel/plugin-syntax-object-rest-spread": "7.8.3", + "@mdx-js/util": "1.6.22", + "babel-plugin-apply-mdx-type-prop": "1.6.22", + "babel-plugin-extract-import-names": "1.6.22", + "camelcase-css": "2.0.1", + "detab": "2.0.4", + "hast-util-raw": "6.0.1", + "lodash.uniq": "4.5.0", + "mdast-util-to-hast": "10.0.1", + "remark-footnotes": "2.0.0", + "remark-mdx": "1.6.22", + "remark-parse": "8.0.3", + "remark-squeeze-paragraphs": "4.0.0", + "style-to-object": "0.3.0", + "unified": "9.2.0", + "unist-builder": "2.0.3", + "unist-util-visit": "2.0.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@mdx-js/mdx/node_modules/@babel/core": { + "version": "7.12.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", + "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.5", + "@babel/helper-module-transforms": "^7.12.1", + "@babel/helpers": "^7.12.5", + "@babel/parser": "^7.12.7", + "@babel/template": "^7.12.7", + "@babel/traverse": "^7.12.9", + "@babel/types": "^7.12.7", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@mdx-js/mdx/node_modules/@babel/plugin-syntax-jsx": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz", + "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@mdx-js/mdx/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/@mdx-js/mdx/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@mdx-js/react": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz", + "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + } + }, + "node_modules/@mdx-js/util": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz", + "integrity": "sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==" + }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", + "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, + "node_modules/@sindresorhus/is": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@slorber/static-site-generator-webpack-plugin/-/static-site-generator-webpack-plugin-4.0.4.tgz", + "integrity": "sha512-FvMavoWEIePps6/JwGCOLYKCRhuwIHhMtmbKpBFgzNkxwpa/569LfTkrbRk1m1I3n+ezJK4on9E1A6cjuZmD9g==", + "dependencies": { + "bluebird": "^3.7.1", + "cheerio": "^0.22.0", + "eval": "^0.1.8", + "webpack-sources": "^1.4.3" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/cheerio": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz", + "integrity": "sha1-qbqoYKP5tZWmuBsahocxIe06Jp4=", + "dependencies": { + "css-select": "~1.2.0", + "dom-serializer": "~0.1.0", + "entities": "~1.1.1", + "htmlparser2": "^3.9.1", + "lodash.assignin": "^4.0.9", + "lodash.bind": "^4.1.4", + "lodash.defaults": "^4.0.1", + "lodash.filter": "^4.4.0", + "lodash.flatten": "^4.2.0", + "lodash.foreach": "^4.3.0", + "lodash.map": "^4.4.0", + "lodash.merge": "^4.4.0", + "lodash.pick": "^4.2.1", + "lodash.reduce": "^4.4.0", + "lodash.reject": "^4.4.0", + "lodash.some": "^4.4.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/css-select": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", + "dependencies": { + "boolbase": "~1.0.0", + "css-what": "2.1", + "domutils": "1.5.1", + "nth-check": "~1.0.1" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/css-what": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz", + "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==", + "engines": { + "node": "*" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/dom-serializer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", + "integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==", + "dependencies": { + "domelementtype": "^1.3.0", + "entities": "^1.1.1" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "dependencies": { + "domelementtype": "1" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==" + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "dependencies": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "node_modules/@slorber/static-site-generator-webpack-plugin/node_modules/nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dependencies": { + "boolbase": "~1.0.0" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz", + "integrity": "sha512-MdPdhdWLtQsjd29Wa4pABdhWbaRMACdM1h31BY+c6FghTZqNGT7pEYdBoaGeKtdTOBC/XNFQaKVj+r/Ei2ryWA==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-6.0.0.tgz", + "integrity": "sha512-aVdtfx9jlaaxc3unA6l+M9YRnKIZjOhQPthLKqmTXC8UVkBLDRGwPKo+r8n3VZN8B34+yVajzPTZ+ptTSuZZCw==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-6.0.0.tgz", + "integrity": "sha512-Ccj42ApsePD451AZJJf1QzTD1B/BOU392URJTeXFxSK709i0KUsGtbwyiqsKu7vsYxpTM0IA5clAKDyf9RCZyA==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-6.0.0.tgz", + "integrity": "sha512-88V26WGyt1Sfd1emBYmBJRWMmgarrExpKNVmI9vVozha4kqs6FzQJ/Kp5+EYli1apgX44518/0+t9+NU36lThQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-6.0.0.tgz", + "integrity": "sha512-F7YXNLfGze+xv0KMQxrl2vkNbI9kzT9oDK55/kUuymh1ACyXkMV+VZWX1zEhSTfEKh7VkHVZGmVtHg8eTZ6PRg==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-6.0.0.tgz", + "integrity": "sha512-+rghFXxdIqJNLQK08kwPBD3Z22/0b2tEZ9lKiL/yTfuyj1wW8HUXu4bo/XkogATIYuXSghVQOOCwURXzHGKyZA==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-6.0.0.tgz", + "integrity": "sha512-VaphyHZ+xIKv5v0K0HCzyfAaLhPGJXSk2HkpYfXIOKb7DjLBv0soHDxNv6X0vr2titsxE7klb++u7iOf7TSrFQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-6.2.0.tgz", + "integrity": "sha512-bhYIpsORb++wpsp91fymbFkf09Z/YEKR0DnFjxvN+8JHeCUD2unnh18jIMKnDJTWtvpTaGYPXELVe4OOzFI0xg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-6.2.0.tgz", + "integrity": "sha512-4WQNY0J71JIaL03DRn0vLiz87JXx0b9dYm2aA8XHlQJQoixMl4r/soYHm8dsaJZ3jWtkCiOYy48dp9izvXhDkQ==", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^6.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^6.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^6.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^6.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "^6.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "^6.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "^6.0.0", + "@svgr/babel-plugin-transform-svg-component": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-6.2.1.tgz", + "integrity": "sha512-NWufjGI2WUyrg46mKuySfviEJ6IxHUOm/8a3Ph38VCWSp+83HBraCQrpEM3F3dB6LBs5x8OElS8h3C0oOJaJAA==", + "dependencies": { + "@svgr/plugin-jsx": "^6.2.1", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-6.2.1.tgz", + "integrity": "sha512-pt7MMkQFDlWJVy9ULJ1h+hZBDGFfSCwlBNW1HkLnVi7jUhyEXUaGYWi1x6bM2IXuAR9l265khBT4Av4lPmaNLQ==", + "dependencies": { + "@babel/types": "^7.15.6", + "entities": "^3.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-6.2.1.tgz", + "integrity": "sha512-u+MpjTsLaKo6r3pHeeSVsh9hmGRag2L7VzApWIaS8imNguqoUwDq/u6U/NDmYs/KAsrmtBjOEaAAPbwNGXXp1g==", + "dependencies": { + "@babel/core": "^7.15.5", + "@svgr/babel-preset": "^6.2.0", + "@svgr/hast-util-to-babel-ast": "^6.2.1", + "svg-parser": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "^6.0.0" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-6.2.0.tgz", + "integrity": "sha512-oDdMQONKOJEbuKwuy4Np6VdV6qoaLLvoY86hjvQEgU82Vx1MSWRyYms6Sl0f+NtqxLI/rDVufATbP/ev996k3Q==", + "dependencies": { + "cosmiconfig": "^7.0.1", + "deepmerge": "^4.2.2", + "svgo": "^2.5.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "^6.0.0" + } + }, + "node_modules/@svgr/webpack": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-6.2.1.tgz", + "integrity": "sha512-h09ngMNd13hnePwgXa+Y5CgOjzlCvfWLHg+MBnydEedAnuLRzUHUJmGS3o2OsrhxTOOqEsPOFt5v/f6C5Qulcw==", + "dependencies": { + "@babel/core": "^7.15.5", + "@babel/plugin-transform-react-constant-elements": "^7.14.5", + "@babel/preset-env": "^7.15.6", + "@babel/preset-react": "^7.14.5", + "@babel/preset-typescript": "^7.15.0", + "@svgr/core": "^6.2.1", + "@svgr/plugin-jsx": "^6.2.1", + "@svgr/plugin-svgo": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", + "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dependencies": { + "defer-to-connect": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", + "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz", + "integrity": "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", + "integrity": "sha512-GE44+DNEyxxh2Kc6ro/VkIj+9ma0pO0bwv9+uHSyBrikYOHr8zYcdPvnBOp1aw8s+CjRvuSx7CyWqRrNFQ59mA==", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "0.0.51", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", + "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" + }, + "node_modules/@types/express": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", + "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "node_modules/@types/hast": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", + "integrity": "sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.9", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz", + "integrity": "sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" + }, + "node_modules/@types/mdast": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", + "integrity": "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + }, + "node_modules/@types/node": { + "version": "17.0.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.30.tgz", + "integrity": "sha512-oNBIZjIqyHYP8VCNAV9uEytXVeXG2oR0w9lgAXro20eugRQfY002qr3CUl6BAe+Yf/z3CRjPdz27Pu6WWtuSRw==" + }, + "node_modules/@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + }, + "node_modules/@types/parse5": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz", + "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" + }, + "node_modules/@types/react": { + "version": "17.0.44", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz", + "integrity": "sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g==", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.18.tgz", + "integrity": "sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g==", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-config": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.6.tgz", + "integrity": "sha512-db1mx37a1EJDf1XeX8jJN7R3PZABmJQXR8r28yUjVMFSjkmnQo6X6pOEEmNl+Tp2gYQOGPdYbFIipBtdElZ3Yg==", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, + "node_modules/@types/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-pSAff4IAxJjfAXUG6tFkO7dsSbTmf8CtUpfhhZ5VhkRpC4628tJhh3+V6H1E+/Gs9piSzYKT5yzHO5M4GG9jkw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" + }, + "node_modules/@types/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", + "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", + "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", + "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==" + }, + "node_modules/@types/ws": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", + "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", + "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", + "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", + "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", + "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", + "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", + "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", + "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", + "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", + "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", + "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/helper-wasm-section": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-opt": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "@webassemblyjs/wast-printer": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", + "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", + "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", + "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", + "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", + "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.0.tgz", + "integrity": "sha512-tNEZYz5G/zYunxFm7sfhAxkXEuLj3K6BKwv6ZURlsF6yiUQ65z0Q2wZW9L5cPUl9ocofGvXOdFYbFHp0+6MOig==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/algoliasearch": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.13.0.tgz", + "integrity": "sha512-oHv4faI1Vl2s+YC0YquwkK/TsaJs79g2JFg5FDm2rKN12VItPTAeQ7hyJMHarOPPYuCnNC5kixbtcqvb21wchw==", + "dependencies": { + "@algolia/cache-browser-local-storage": "4.13.0", + "@algolia/cache-common": "4.13.0", + "@algolia/cache-in-memory": "4.13.0", + "@algolia/client-account": "4.13.0", + "@algolia/client-analytics": "4.13.0", + "@algolia/client-common": "4.13.0", + "@algolia/client-personalization": "4.13.0", + "@algolia/client-search": "4.13.0", + "@algolia/logger-common": "4.13.0", + "@algolia/logger-console": "4.13.0", + "@algolia/requester-browser-xhr": "4.13.0", + "@algolia/requester-common": "4.13.0", + "@algolia/requester-node-http": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "node_modules/algoliasearch-helper": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.8.2.tgz", + "integrity": "sha512-AXxiF0zT9oYwl8ZBgU/eRXvfYhz7cBA5YrLPlw9inZHdaYF0QEya/f1Zp1mPYMXc1v6VkHwBq4pk6/vayBLICg==", + "dependencies": { + "@algolia/events": "^4.0.1" + }, + "peerDependencies": { + "algoliasearch": ">= 3.1 < 5" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", + "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.7", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.7.tgz", + "integrity": "sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + } + ], + "dependencies": { + "browserslist": "^4.20.3", + "caniuse-lite": "^1.0.30001335", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", + "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "dependencies": { + "follow-redirects": "^1.14.7" + } + }, + "node_modules/babel-loader": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", + "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-plugin-apply-mdx-type-prop": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz", + "integrity": "sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==", + "dependencies": { + "@babel/helper-plugin-utils": "7.10.4", + "@mdx-js/util": "1.6.22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@babel/core": "^7.11.6" + } + }, + "node_modules/babel-plugin-apply-mdx-type-prop/node_modules/@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + }, + "node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz", + "integrity": "sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==", + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/babel-plugin-extract-import-names": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz", + "integrity": "sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ==", + "dependencies": { + "@babel/helper-plugin-utils": "7.10.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/babel-plugin-extract-import-names/node_modules/@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.1.tgz", + "integrity": "sha512-v7/T6EQcNfVLfcN2X8Lulb7DjprieyLWJK/zOWH5DUYcAgex9sP3h25Q+DLsX9TloXe3y1O8l2q2Jv9q8UVB9w==", + "dependencies": { + "@babel/compat-data": "^7.13.11", + "@babel/helper-define-polyfill-provider": "^0.3.1", + "semver": "^6.1.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz", + "integrity": "sha512-G3uJih0XWiID451fpeFaYGVuxHEjzKTHtc9uGFEjR6hHrvNzeS/PX+LLLcetJcytsB5m4j+K3o/EpXJNb/5IEQ==", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.3.1", + "core-js-compat": "^3.21.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.1.tgz", + "integrity": "sha512-Y2B06tvgHYt1x0yz17jGkGeeMr5FeKUu+ASJ+N6nB5lQ8Dapfg42i0OVrf8PNGJ3zKL4A23snMi1IRwrqqND7A==", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base16": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", + "integrity": "sha1-4pf2DX7BAUp6lxo568ipjAtoHnA=" + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=" + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "node_modules/body-parser": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", + "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.10.3", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/bonjour-service": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.12.tgz", + "integrity": "sha512-pMmguXYCu63Ug37DluMKEHdxc+aaIf/ay4YbF8Gxtba+9d3u+rmEWy61VK3Z3hp8Rskok3BunHYnG0dUHAsblw==", + "dependencies": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.4" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" + }, + "node_modules/boxen": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-6.2.1.tgz", + "integrity": "sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^6.2.0", + "chalk": "^4.1.2", + "cli-boxes": "^3.0.0", + "string-width": "^5.0.1", + "type-fest": "^2.5.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/boxen/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/boxen/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/boxen/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", + "integrity": "sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001332", + "electron-to-chromium": "^1.4.118", + "escalade": "^3.1.1", + "node-releases": "^2.0.3", + "picocolors": "^1.0.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", + "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^3.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^1.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-request/node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/normalize-url": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001339", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001339.tgz", + "integrity": "sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ] + }, + "node_modules/ccount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", + "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz", + "integrity": "sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw==", + "dependencies": { + "cheerio-select": "^1.5.0", + "dom-serializer": "^1.3.2", + "domhandler": "^4.2.0", + "htmlparser2": "^6.1.0", + "parse5": "^6.0.1", + "parse5-htmlparser2-tree-adapter": "^6.0.1", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-1.6.0.tgz", + "integrity": "sha512-eq0GdBvxVFbqWgmCm7M3XGs1I8oLy/nExUnh6oLqmBditPO9AqQJrkslDpMun/hZ0yyTs8L0m85OHp4ho6Qm9g==", + "dependencies": { + "css-select": "^4.3.0", + "css-what": "^6.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.3.1", + "domutils": "^2.8.0" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + }, + "node_modules/clean-css": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz", + "integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.2.tgz", + "integrity": "sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw==", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "dependencies": { + "mimic-response": "^1.0.0" + } + }, + "node_modules/clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz", + "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "node_modules/colord": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz", + "integrity": "sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==" + }, + "node_modules/colorette": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", + "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==" + }, + "node_modules/combine-promises": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/combine-promises/-/combine-promises-1.1.0.tgz", + "integrity": "sha512-ZI9jvcLDxqwaXEixOhArm3r7ReIivsXkpbyEWyeOhzz1QS0iSgBPnWvEqvIQtYyamGCYA88gFhmUrs9hrrQ0pg==", + "engines": { + "node": ">=10" + } + }, + "node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compressible/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "node_modules/configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dependencies": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz", + "integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "dependencies": { + "safe-buffer": "~5.1.1" + } + }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "node_modules/copy-text-to-clipboard": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz", + "integrity": "sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", + "integrity": "sha512-xFVltahqlsRcyyJqQbDY6EYTtyQZF9rf+JPjwHObLdPFMEISqkFkr7mFoVOC6BfYS/dNThyoQKvziugm+OnwBg==", + "dependencies": { + "fast-glob": "^3.2.7", + "glob-parent": "^6.0.1", + "globby": "^12.0.2", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 12.20.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/copy-webpack-plugin/node_modules/array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz", + "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==", + "dependencies": { + "array-union": "^3.0.1", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.7", + "ignore": "^5.1.9", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-js": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.5.tgz", + "integrity": "sha512-VP/xYuvJ0MJWRAobcmQ8F2H6Bsn+s7zqAAjFaHGBMc5AQm7zaelhD1LGduFn2EehEcQcU+br6t+fwbpQ5d1ZWA==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.22.3.tgz", + "integrity": "sha512-wliMbvPI2idgFWpFe7UEyHMvu6HWgW8WA+HnDRtgzoSDYvXFMpoGX1H3tPDDXrcfUSyXafCLDd7hOeMQHEZxGw==", + "dependencies": { + "browserslist": "^4.20.3", + "semver": "7.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/core-js-pure": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.3.tgz", + "integrity": "sha512-oN88zz7nmKROMy8GOjs+LN+0LedIvbMdnB5XsTlhcOg1WGARt9l0LFg0zohdoFmCsEZ1h2ZbSQ6azj3M+vhzwQ==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cosmiconfig": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "dependencies": { + "node-fetch": "2.6.7" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.2.2.tgz", + "integrity": "sha512-Ufadglr88ZLsrvS11gjeu/40Lw74D9Am/Jpr3LlYm5Q4ZP5KdlUhG+6u2EjyXeZcxmZ2h1ebCKngDjolpeLHpg==", + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-loader": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz", + "integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.7", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", + "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "dependencies": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.7.tgz", + "integrity": "sha512-pVsUV6LcTXif7lvKKW9ZrmX+rGRzxkEdJuVJcp5ftUjWITgwam5LMZOgaTvUrWPkcORBey6he7JKb4XAJvrpKg==", + "dependencies": { + "cssnano-preset-default": "^5.2.7", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-advanced": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.3.tgz", + "integrity": "sha512-AB9SmTSC2Gd8T7PpKUsXFJ3eNsg7dc4CTZ0+XAJ29MNxyJsrCEk7N1lw31bpHrsQH2PVJr21bbWgGAfA9j0dIA==", + "dependencies": { + "autoprefixer": "^10.3.7", + "cssnano-preset-default": "^5.2.7", + "postcss-discard-unused": "^5.1.0", + "postcss-merge-idents": "^5.1.1", + "postcss-reduce-idents": "^5.2.0", + "postcss-zindex": "^5.1.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.7.tgz", + "integrity": "sha512-JiKP38ymZQK+zVKevphPzNSGHSlTI+AOwlasoSRtSVMUU285O7/6uZyd5NbW92ZHp41m0sSHe6JoZosakj63uA==", + "dependencies": { + "css-declaration-sorter": "^6.2.2", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.0", + "postcss-convert-values": "^5.1.0", + "postcss-discard-comments": "^5.1.1", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.4", + "postcss-merge-rules": "^5.1.1", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.2", + "postcss-minify-selectors": "^5.2.0", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.0", + "postcss-normalize-repeat-style": "^5.1.0", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.0", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.1", + "postcss-reduce-initial": "^5.1.0", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/defer-to-connect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", + "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "dependencies": { + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/del": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/del/-/del-6.0.0.tgz", + "integrity": "sha512-1shh9DQ23L16oXSZKB2JxpL7iMy2E0S9d517ptA1P8iw0alkPtQcrKH7ru31rYtKwF499HkTu+DRzq3TCKDFRQ==", + "dependencies": { + "globby": "^11.0.1", + "graceful-fs": "^4.2.4", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.2", + "p-map": "^4.0.0", + "rimraf": "^3.0.2", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detab": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detab/-/detab-2.0.4.tgz", + "integrity": "sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==", + "dependencies": { + "repeat-string": "^1.5.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, + "node_modules/detect-port": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.3.0.tgz", + "integrity": "sha512-E+B1gzkl2gqxt1IhUzwjrxBKRqx1UzC3WLONHinn8S3T6lwV/agVCyitiFOsGJ/eYuEUBvD71MZHy3Pv1G9doQ==", + "dependencies": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "bin": { + "detect": "bin/detect-port", + "detect-port": "bin/detect-port" + }, + "engines": { + "node": ">= 4.2.1" + } + }, + "node_modules/detect-port-alt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", + "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "dependencies": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "bin": { + "detect": "bin/detect-port", + "detect-port": "bin/detect-port" + }, + "engines": { + "node": ">= 4.2.1" + } + }, + "node_modules/detect-port-alt/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/detect-port-alt/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/detect-port/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/detect-port/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha1-s55/HabrCnW6nBcySzR1PEfgZU0=" + }, + "node_modules/dns-packet": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.3.1.tgz", + "integrity": "sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw==", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop/node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + }, + "node_modules/duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "node_modules/electron-to-chromium": { + "version": "1.4.127", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.127.tgz", + "integrity": "sha512-nhD6S8nKI0O2MueC6blNOEZio+/PWppE/pevnf3LOlQA/fKPCrDp2Ao4wx4LFwmIkJpVdFdn2763YWLy9ENIZg==" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/emoticon": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-3.2.0.tgz", + "integrity": "sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz", + "integrity": "sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-module-lexer": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/eta/-/eta-1.12.3.tgz", + "integrity": "sha512-qHixwbDLtekO/d51Yr4glcaUJCIjGVJyTzuqV4GPlgZo1YpgOKG+avQynErZIYrfM6JIJdtiG2Kox8tbb+DoGg==", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eval": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.8.tgz", + "integrity": "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==", + "dependencies": { + "@types/node": "*", + "require-like": ">= 0.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/express": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz", + "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.0", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.10.3", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "node_modules/express/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "node_modules/express/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/fast-url-parser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", + "integrity": "sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0=", + "dependencies": { + "punycode": "^1.3.2" + } + }, + "node_modules/fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fbemitter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz", + "integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==", + "dependencies": { + "fbjs": "^3.0.0" + } + }, + "node_modules/fbjs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.4.tgz", + "integrity": "sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ==", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.30" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==" + }, + "node_modules/feed": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", + "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", + "dependencies": { + "xml-js": "^1.6.11" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flux": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.3.tgz", + "integrity": "sha512-yKAbrp7JhZhj6uiT1FTuVMlIAT1J4jqEyBpFApi1kxpGZCvacMVc/t1pMQyotqHhAgvoE3bNvAykhCo2CLjnYw==", + "dependencies": { + "fbemitter": "^3.0.0", + "fbjs": "^3.0.1" + }, + "peerDependencies": { + "react": "^15.0.2 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.14.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", + "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", + "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", + "dependencies": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "engines": { + "node": ">=10", + "yarn": ">=1.0.0" + }, + "peerDependencies": { + "eslint": ">= 6", + "typescript": ">= 2.7", + "vue-template-compiler": "*", + "webpack": ">= 4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dependencies": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" + }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/github-slugger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.4.0.tgz", + "integrity": "sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==" + }, + "node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + }, + "node_modules/global-dirs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz", + "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-dirs/node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", + "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dependencies": { + "@sindresorhus/is": "^0.14.0", + "@szmarczak/http-timer": "^1.1.2", + "cacheable-request": "^6.0.0", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^4.1.0", + "lowercase-keys": "^1.0.1", + "mimic-response": "^1.0.1", + "p-cancelable": "^1.0.0", + "to-readable-stream": "^1.0.0", + "url-parse-lax": "^3.0.0" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "dependencies": { + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-yarn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-to-hyperscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", + "integrity": "sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==", + "dependencies": { + "@types/unist": "^2.0.3", + "comma-separated-tokens": "^1.0.0", + "property-information": "^5.3.0", + "space-separated-tokens": "^1.0.0", + "style-to-object": "^0.3.0", + "unist-util-is": "^4.0.0", + "web-namespaces": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-6.0.1.tgz", + "integrity": "sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==", + "dependencies": { + "@types/parse5": "^5.0.0", + "hastscript": "^6.0.0", + "property-information": "^5.0.0", + "vfile": "^4.0.0", + "vfile-location": "^3.2.0", + "web-namespaces": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-6.0.1.tgz", + "integrity": "sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig==", + "dependencies": { + "@types/hast": "^2.0.0", + "hast-util-from-parse5": "^6.0.0", + "hast-util-to-parse5": "^6.0.0", + "html-void-elements": "^1.0.0", + "parse5": "^6.0.0", + "unist-util-position": "^3.0.0", + "vfile": "^4.0.0", + "web-namespaces": "^1.0.0", + "xtend": "^4.0.0", + "zwitch": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz", + "integrity": "sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==", + "dependencies": { + "hast-to-hyperscript": "^9.0.0", + "property-information": "^5.0.0", + "web-namespaces": "^1.0.0", + "xtend": "^4.0.0", + "zwitch": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/html-tags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz", + "integrity": "sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/html-void-elements": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz", + "integrity": "sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "webpack": "^5.20.0" + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.6.tgz", + "integrity": "sha512-vDlkRPDJn93swjcjqMSaGSPABbIarsr1TLAui/gLDXzV5VsJNdXNzMYDyNBLQkjWQCJ1uizu8T2oDMhmGt0PRA==" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.1.tgz", + "integrity": "sha512-VAwkvNSNGClRw9mDHhc5Efax8PLlsOGcUTh0T/LIriC8vPA3U5PdqXWqkz406MoYHMKW8Uf9gWr05T/rYB44kQ==", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/immer": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", + "integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", + "engines": { + "node": ">=4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/infima": { + "version": "0.2.0-alpha.39", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.39.tgz", + "integrity": "sha512-UyYiwD3nwHakGhuOUfpe3baJ8gkiPpRVx4a4sE/Ag+932+Y6swtLsdPoRR8ezhwqGnduzxmFkjumV9roz6QoLw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", + "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } + }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-npm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-5.0.0.tgz", + "integrity": "sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-root": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "node_modules/is-whitespace-character": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", + "integrity": "sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-word-character": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz", + "integrity": "sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-yarn-global": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==" + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.6.0.tgz", + "integrity": "sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==", + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.0", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", + "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dependencies": { + "json-buffer": "3.0.0" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", + "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/latest-version": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", + "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "dependencies": { + "package-json": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz", + "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.assignin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz", + "integrity": "sha1-uo31+4QesKPoBEIysOJjqNxqKKI=" + }, + "node_modules/lodash.bind": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz", + "integrity": "sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU=" + }, + "node_modules/lodash.curry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz", + "integrity": "sha1-JI42By7ekGUB11lmIAqG2riyMXA=" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + }, + "node_modules/lodash.filter": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz", + "integrity": "sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4=" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, + "node_modules/lodash.flow": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", + "integrity": "sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o=" + }, + "node_modules/lodash.foreach": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", + "integrity": "sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM=" + }, + "node_modules/lodash.map": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", + "integrity": "sha1-dx7Hg540c9nEzeKLGTlMNWL09tM=" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "node_modules/lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs=" + }, + "node_modules/lodash.reject": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reject/-/lodash.reject-4.6.0.tgz", + "integrity": "sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU=" + }, + "node_modules/lodash.some": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz", + "integrity": "sha1-G7nzFO9ri63tE7VJFpsqlF62jk0=" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/markdown-escapes": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz", + "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-squeeze-paragraphs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz", + "integrity": "sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ==", + "dependencies": { + "unist-util-remove": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-definitions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz", + "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==", + "dependencies": { + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz", + "integrity": "sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA==", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "mdast-util-definitions": "^4.0.0", + "mdurl": "^1.0.0", + "unist-builder": "^2.0.0", + "unist-util-generated": "^1.0.0", + "unist-util-position": "^3.0.0", + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.1.tgz", + "integrity": "sha512-1c9VPVvW5P7I85c35zAdEr1TD5+F11IToIHIlrVIcflfnzPkJa0ZoYEoEdYDP8KgPFoSZ/opDrUsAoZWym3mtw==", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-create-react-context": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", + "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", + "dependencies": { + "@babel/runtime": "^7.12.1", + "tiny-warning": "^1.0.3" + }, + "peerDependencies": { + "prop-types": "^15.0.0", + "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.0.tgz", + "integrity": "sha512-ndG8nxCEnAemsg4FSgS+yNyHKgkTB4nPKqCOgh65j3/30qqC5RaSQQXMm++Y6sb6E1zRSxPkztj9fqxhS1Eo6w==", + "dependencies": { + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" + }, + "node_modules/mrmime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.0.tgz", + "integrity": "sha512-a70zx7zFfVO7XpnQ2IX1Myh9yY4UYvfld/dikWRnsXxbyvMcfz+u6UfgNAtH+k2QqtJuzVpv6eLTx1G2+WKZbQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/multicast-dns": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.4.tgz", + "integrity": "sha512-XkCYOU+rr2Ft3LI6w4ye51M3VK31qJXFIxu0XLw169PtKG0Zx47OrXeVW/GCYOfpC9s1yyyf1S+L8/4LY0J9Zw==", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.4.tgz", + "integrity": "sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha1-y480xTIT2JVyP8urkH6UIq28r7E=" + }, + "node_modules/nth-check": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", + "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", + "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", + "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/p-cancelable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", + "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "dependencies": { + "got": "^9.6.0", + "registry-auth-token": "^4.0.0", + "registry-url": "^5.0.0", + "semver": "^6.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-numeric-range": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", + "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.4.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.13.tgz", + "integrity": "sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "dependencies": { + "nanoid": "^3.3.3", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", + "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", + "dependencies": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.0.tgz", + "integrity": "sha512-GkyPbZEYJiWtQB0KZ0X6qusqFHUepguBCNFi9t5JJc7I2OTXG7C0twbTLvCfaKOLl3rSXmpAwV7W5txd91V84g==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.1.tgz", + "integrity": "sha512-5JscyFmvkUxz/5/+TB3QTTT9Gi9jHkcn8dcmmuN68JQcv3aQg4y88yEHHhwFB52l/NkaJ43O0dbksGMAo49nfQ==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-unused": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-5.1.0.tgz", + "integrity": "sha512-KwLWymI9hbwXmJa0dkrzpRbSJEh0vVUd7r8t0yOGPcfKzyJJxFM8kLyC5Ev9avji6nY95pOp1W6HqIrfT+0VGw==", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-loader": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", + "dependencies": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-merge-idents": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-5.1.1.tgz", + "integrity": "sha512-pCijL1TREiCoog5nQp7wUe+TUonA2tC2sQ54UGeMmryK3UFGIYKqDyjnqd6RcuI4znFn9hWSLNN8xKE/vWcUQw==", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.4.tgz", + "integrity": "sha512-hbqRRqYfmXoGpzYKeW0/NCZhvNyQIlQeWVSao5iKWdyx7skLvCfQFGIUsP9NUs3dSbPac2IC4Go85/zG+7MlmA==", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.1.tgz", + "integrity": "sha512-8wv8q2cXjEuCcgpIB1Xx1pIy8/rhMPIQqYKNzEdyx37m6gpq83mQQdCxgIkFgliyEnKvdwJf/C61vN4tQDq4Ww==", + "dependencies": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.2.tgz", + "integrity": "sha512-aEP+p71S/urY48HWaRHasyx4WHQJyOYaKpQ6eXl8k0kxg66Wt/30VR6/woh8THgcpRbonJD5IeD+CzNhPi1L8g==", + "dependencies": { + "browserslist": "^4.16.6", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.0.tgz", + "integrity": "sha512-vYxvHkW+iULstA+ctVNx0VoRAR4THQQRkG77o0oa4/mBS0OzGvvzLIvHDv/nNEM0crzN2WIyFU5X7wZhaUK3RA==", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.0.tgz", + "integrity": "sha512-8gmItgA4H5xiUxgN/3TVvXRoJxkAWLW6f/KKhdsH03atg0cB8ilXnrB5PpSshwVu/dD2ZsRFQcR1OEmSBDAgcQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.0.tgz", + "integrity": "sha512-IR3uBjc+7mcWGL6CtniKNQ4Rr5fTxwkaDHwMBDGGs1x9IVRkYIT/M4NelZWkAOBdV6v3Z9S46zqaKGlyzHSchw==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz", + "integrity": "sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==", + "dependencies": { + "browserslist": "^4.16.6", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.1.tgz", + "integrity": "sha512-7lxgXF0NaoMIgyihL/2boNAEZKiW0+HkMhdKMTD93CjW8TdCy2hSdj8lsAo+uwm7EDG16Da2Jdmtqpedl0cMfw==", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-idents": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-5.2.0.tgz", + "integrity": "sha512-BTrLjICoSB6gxbc58D5mdBK8OhXRDqud/zodYfdSi52qvDHdMwk+9kB9xsM8yJThH/sZU5A6QVSmMmaN001gIg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz", + "integrity": "sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==", + "dependencies": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-sort-media-queries": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-4.2.1.tgz", + "integrity": "sha512-9VYekQalFZ3sdgcTjXMa0dDjsfBVHXlraYJEMiOJ/2iMmI2JGCMavP16z3kWOaRu8NSaJCTgVpB/IVpH5yT9YQ==", + "dependencies": { + "sort-css-media-queries": "2.0.4" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.4.4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/postcss-zindex": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-5.1.0.tgz", + "integrity": "sha512-fgFMf0OtVSBR1va1JNHYgMxYk73yhn/qb4uQDq1DLGYolz8gHCyr/sesEuGUaYs58E3ZJRcpoGuPVoB7Meiq9A==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", + "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/prism-react-renderer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-1.3.1.tgz", + "integrity": "sha512-xUeDMEz074d0zc5y6rxiMp/dlC7C+5IDDlaEUlcBOFE2wddz7hz5PNupb087mPwTt7T9BrFmewObfCBuf/LKwQ==", + "peerDependencies": { + "react": ">=0.14.9" + } + }, + "node_modules/prismjs": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.28.0.tgz", + "integrity": "sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "node_modules/pupa": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", + "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", + "dependencies": { + "escape-goat": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-color": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz", + "integrity": "sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4=" + }, + "node_modules/qs": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", + "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-base16-styling": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz", + "integrity": "sha1-7yFW1mz0E5aVyKFniGy2nqZgeSw=", + "dependencies": { + "base16": "^1.0.0", + "lodash.curry": "^4.0.1", + "lodash.flow": "^3.3.0", + "pure-color": "^1.2.0" + } + }, + "node_modules/react-dev-utils": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/react-dev-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/react-dev-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/react-dev-utils/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/react-dev-utils/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-dev-utils/node_modules/loader-utils": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.0.tgz", + "integrity": "sha512-HVl9ZqccQihZ7JM85dco1MvO9G+ONvxoGa9rkhzFsneGLKSUg1gJf9bWzhRhcvm2qChhWpebQhP44qxjKIUCaQ==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/react-dev-utils/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + }, + "peerDependencies": { + "react": "17.0.2" + } + }, + "node_modules/react-error-overlay": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", + "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" + }, + "node_modules/react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, + "node_modules/react-helmet-async": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", + "integrity": "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-json-view": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/react-json-view/-/react-json-view-1.21.3.tgz", + "integrity": "sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==", + "dependencies": { + "flux": "^4.0.1", + "react-base16-styling": "^0.6.0", + "react-lifecycles-compat": "^3.0.4", + "react-textarea-autosize": "^8.3.2" + }, + "peerDependencies": { + "react": "^17.0.0 || ^16.3.0 || ^15.5.4", + "react-dom": "^17.0.0 || ^16.3.0 || ^15.5.4" + } + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "node_modules/react-loadable": { + "name": "@docusaurus/react-loadable", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz", + "integrity": "sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==", + "dependencies": { + "@types/react": "*", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-loadable-ssr-addon-v5-slorber": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz", + "integrity": "sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==", + "dependencies": { + "@babel/runtime": "^7.10.3" + }, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "react-loadable": "*", + "webpack": ">=4.41.1 || 5.x" + } + }, + "node_modules/react-router": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.1.tgz", + "integrity": "sha512-v+zwjqb7bakqgF+wMVKlAPTca/cEmPOvQ9zt7gpSNyPXau1+0qvuYZ5BWzzNDP1y6s15zDwgb9rPN63+SIniRQ==", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router-config": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-router-config/-/react-router-config-5.1.1.tgz", + "integrity": "sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==", + "dependencies": { + "@babel/runtime": "^7.1.2" + }, + "peerDependencies": { + "react": ">=15", + "react-router": ">=5" + } + }, + "node_modules/react-router-dom": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.1.tgz", + "integrity": "sha512-f0pj/gMAbv9e8gahTmCEY20oFhxhrmHwYeIwH5EO5xu0qme+wXtsdB8YfUOAZzUz4VaXmb58m3ceiLtjMhqYmQ==", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.1", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz", + "integrity": "sha512-2XlHXK2TDxS6vbQaoPbMOfQ8GK7+irc2fVK6QFIcC8GOnH3zI/v481n+j1L0WaPVvKxwesnY93fEfH++sus2rQ==", + "dependencies": { + "@babel/runtime": "^7.10.2", + "use-composed-ref": "^1.0.0", + "use-latest": "^1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reading-time": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", + "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==" + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/recursive-readdir": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz", + "integrity": "sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg==", + "dependencies": { + "minimatch": "3.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recursive-readdir/node_modules/minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz", + "integrity": "sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" + }, + "node_modules/regenerator-transform": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz", + "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexpu-core": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.0.1.tgz", + "integrity": "sha512-CriEZlrKK9VJw/xQGJpQM5rY88BtuL8DM+AEwvcThHilbxiTAy8vq4iJnd2tqq8wLmjbGZzP7ZcKFjbGkmEFrw==", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.0.1", + "regjsgen": "^0.6.0", + "regjsparser": "^0.8.2", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/registry-auth-token": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz", + "integrity": "sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==", + "dependencies": { + "rc": "^1.2.8" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/registry-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", + "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "dependencies": { + "rc": "^1.2.8" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regjsgen": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.6.0.tgz", + "integrity": "sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA==" + }, + "node_modules/regjsparser": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.8.4.tgz", + "integrity": "sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA==", + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/rehype-parse": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-6.0.2.tgz", + "integrity": "sha512-0S3CpvpTAgGmnz8kiCyFLGuW5yA4OQhyNTm/nwPopZ7+PI11WnGl1TTWTGv/2hPEe/g2jRLlhVVSsoDH8waRug==", + "dependencies": { + "hast-util-from-parse5": "^5.0.0", + "parse5": "^5.0.0", + "xtend": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse/node_modules/hast-util-from-parse5": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-5.0.3.tgz", + "integrity": "sha512-gOc8UB99F6eWVWFtM9jUikjN7QkWxB3nY0df5Z0Zq1/Nkwl5V4hAAsl0tmwlgWl/1shlTF8DnNYLO8X6wRV9pA==", + "dependencies": { + "ccount": "^1.0.3", + "hastscript": "^5.0.0", + "property-information": "^5.0.0", + "web-namespaces": "^1.1.2", + "xtend": "^4.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse/node_modules/hastscript": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-5.1.2.tgz", + "integrity": "sha512-WlztFuK+Lrvi3EggsqOkQ52rKbxkXL3RwB6t5lwoa8QLMemoWfBuL43eDrwOamJyR7uKQKdmKYaBH1NZBiIRrQ==", + "dependencies": { + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse/node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/remark-admonitions": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/remark-admonitions/-/remark-admonitions-1.2.1.tgz", + "integrity": "sha512-Ji6p68VDvD+H1oS95Fdx9Ar5WA2wcDA4kwrrhVU7fGctC6+d3uiMICu7w7/2Xld+lnU7/gi+432+rRbup5S8ow==", + "dependencies": { + "rehype-parse": "^6.0.2", + "unified": "^8.4.2", + "unist-util-visit": "^2.0.1" + } + }, + "node_modules/remark-admonitions/node_modules/unified": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-8.4.2.tgz", + "integrity": "sha512-JCrmN13jI4+h9UAyKEoGcDZV+i1E7BLFuG7OsaDvTXI5P0qhHX+vZO/kOhz9jn8HGENDKbwSeB0nVOg4gVStGA==", + "dependencies": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-2.2.0.tgz", + "integrity": "sha512-P3cj9s5ggsUvWw5fS2uzCHJMGuXYRb0NnZqYlNecewXt8QBU9n5vW3DUUKOhepS8F9CwdMx9B8a3i7pqFWAI5w==", + "dependencies": { + "emoticon": "^3.2.0", + "node-emoji": "^1.10.0", + "unist-util-visit": "^2.0.3" + } + }, + "node_modules/remark-footnotes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/remark-footnotes/-/remark-footnotes-2.0.0.tgz", + "integrity": "sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-1.6.22.tgz", + "integrity": "sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ==", + "dependencies": { + "@babel/core": "7.12.9", + "@babel/helper-plugin-utils": "7.10.4", + "@babel/plugin-proposal-object-rest-spread": "7.12.1", + "@babel/plugin-syntax-jsx": "7.12.1", + "@mdx-js/util": "1.6.22", + "is-alphabetical": "1.0.4", + "remark-parse": "8.0.3", + "unified": "9.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx/node_modules/@babel/core": { + "version": "7.12.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", + "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.5", + "@babel/helper-module-transforms": "^7.12.1", + "@babel/helpers": "^7.12.5", + "@babel/parser": "^7.12.7", + "@babel/template": "^7.12.7", + "@babel/traverse": "^7.12.9", + "@babel/types": "^7.12.7", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/remark-mdx/node_modules/@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + }, + "node_modules/remark-mdx/node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz", + "integrity": "sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-transform-parameters": "^7.12.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/remark-mdx/node_modules/@babel/plugin-syntax-jsx": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz", + "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/remark-mdx/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/remark-mdx/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remark-parse": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz", + "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==", + "dependencies": { + "ccount": "^1.0.0", + "collapse-white-space": "^1.0.2", + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-whitespace-character": "^1.0.0", + "is-word-character": "^1.0.0", + "markdown-escapes": "^1.0.0", + "parse-entities": "^2.0.0", + "repeat-string": "^1.5.4", + "state-toggle": "^1.0.0", + "trim": "0.0.1", + "trim-trailing-lines": "^1.0.0", + "unherit": "^1.0.4", + "unist-util-remove-position": "^2.0.0", + "vfile-location": "^3.0.0", + "xtend": "^4.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-squeeze-paragraphs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz", + "integrity": "sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw==", + "dependencies": { + "mdast-squeeze-paragraphs": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-like": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", + "integrity": "sha1-rW8wwTvs15cBDEaK+ndcDAprR/o=", + "engines": { + "node": "*" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, + "node_modules/resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dependencies": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, + "node_modules/responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dependencies": { + "lowercase-keys": "^1.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rtl-detect": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.0.4.tgz", + "integrity": "sha512-EBR4I2VDSSYr7PkBmFy04uhycIpDKp+21p/jARYXlCSjQksTBQcJ0HFUPOO79EPPH5JS6VAhiIQbycf0O3JAxQ==" + }, + "node_modules/rtlcss": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-3.5.0.tgz", + "integrity": "sha512-wzgMaMFHQTnyi9YOwsx9LjOxYXJPzS8sYnFaKm6R5ysvTkwzHiB0vxnbHwchHQT65PTdBjDG21/kQBWI7q9O7A==", + "dependencies": { + "find-up": "^5.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.3.11", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "rtlcss": "bin/rtlcss.js" + } + }, + "node_modules/rtlcss/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rtlcss/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rtlcss/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rtlcss/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz", + "integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "node_modules/scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=" + }, + "node_modules/selfsigned": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.1.tgz", + "integrity": "sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ==", + "dependencies": { + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-diff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", + "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "dependencies": { + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semver-diff/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/send/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-handler": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.3.tgz", + "integrity": "sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w==", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "fast-url-parser": "1.1.3", + "mime-types": "2.1.18", + "minimatch": "3.0.4", + "path-is-inside": "1.0.2", + "path-to-regexp": "2.2.1", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/serve-handler/node_modules/path-to-regexp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz", + "integrity": "sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==" + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==" + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/sirv": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.19.tgz", + "integrity": "sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==", + "dependencies": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^1.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, + "node_modules/sitemap": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-7.1.1.tgz", + "integrity": "sha512-mK3aFtjz4VdJN0igpIJrinf3EO8U8mxOPsTBzSsy06UtjZQJ3YY3o3Xa7zSc5nMqcMrRwlChHZ18Kxg0caiPBg==", + "dependencies": { + "@types/node": "^17.0.5", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.2.4" + }, + "bin": { + "sitemap": "dist/cli.js" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=5.6.0" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/sort-css-media-queries": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sort-css-media-queries/-/sort-css-media-queries-2.0.4.tgz", + "integrity": "sha512-PAIsEK/XupCQwitjv7XxoMvYhT7EAfyzI3hsy/MyDgTvc+Ft55ctdkctJLOy6cQejaIC+zjpUL4djFVm2ivOOw==", + "engines": { + "node": ">= 6.3.0" + } + }, + "node_modules/source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==" + }, + "node_modules/state-toggle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz", + "integrity": "sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.1.1.tgz", + "integrity": "sha512-/c645XdExBypL01TpFKiG/3RAa/Qmu+zRi0MwAmrdEkwHNuN0ebo8ccAXBBDa5Z0QOJgBskUIbuCK91x0sCVEw==" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-object": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", + "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, + "node_modules/stylehacks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.0.tgz", + "integrity": "sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==", + "dependencies": { + "browserslist": "^4.16.6", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" + }, + "node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.13.1.tgz", + "integrity": "sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA==", + "dependencies": { + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map": "~0.8.0-beta.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz", + "integrity": "sha512-GvlZdT6wPQKbDNW/GDQzZFg/j4vKU96yl2q6mcUkzKOgW4gwf1Z8cZToUCrz31XHlPWH8MVb1r2tFtdDtTGJ7g==", + "dependencies": { + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1", + "terser": "^5.7.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/terser/node_modules/punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/terser/node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/terser/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/terser/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + }, + "node_modules/terser/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=" + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, + "node_modules/tiny-invariant": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-readable-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz", + "integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==", + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, + "node_modules/trim": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", + "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=" + }, + "node_modules/trim-trailing-lines": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz", + "integrity": "sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", + "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + }, + "node_modules/type-fest": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.12.2.tgz", + "integrity": "sha512-qt6ylCGpLjZ7AaODxbpyBZSs9fCI9SkL3Z9q2oxMBQhs/uyY+VD8jHA8ULCGmWQJlBgqvO3EJeAngOHD8zQCrQ==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/ua-parser-js": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", + "integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "engines": { + "node": "*" + } + }, + "node_modules/unherit": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz", + "integrity": "sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==", + "dependencies": { + "inherits": "^2.0.0", + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", + "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz", + "integrity": "sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unified": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz", + "integrity": "sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==", + "dependencies": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/unist-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz", + "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-generated": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz", + "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz", + "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-2.1.0.tgz", + "integrity": "sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q==", + "dependencies": { + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz", + "integrity": "sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==", + "dependencies": { + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-notifier": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-5.1.0.tgz", + "integrity": "sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==", + "dependencies": { + "boxen": "^5.0.0", + "chalk": "^4.1.0", + "configstore": "^5.0.1", + "has-yarn": "^2.1.0", + "import-lazy": "^2.1.0", + "is-ci": "^2.0.0", + "is-installed-globally": "^0.4.0", + "is-npm": "^5.0.0", + "is-yarn-global": "^0.3.0", + "latest-version": "^5.1.0", + "pupa": "^2.1.1", + "semver": "^7.3.4", + "semver-diff": "^3.1.1", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/update-notifier/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/update-notifier/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/update-notifier/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/update-notifier/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/update-notifier/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/update-notifier/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/update-notifier/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js/node_modules/punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/url-loader": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", + "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", + "dependencies": { + "loader-utils": "^2.0.0", + "mime-types": "^2.1.27", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "file-loader": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "file-loader": { + "optional": true + } + } + }, + "node_modules/url-loader/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/url-loader/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/url-loader/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/url-parse-lax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dependencies": { + "prepend-http": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/use-composed-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/use-latest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.1.tgz", + "integrity": "sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest/node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=" + }, + "node_modules/utility-types": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz", + "integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", + "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^2.0.0", + "vfile-message": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-3.2.0.tgz", + "integrity": "sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/wait-on": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-6.0.1.tgz", + "integrity": "sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw==", + "dependencies": { + "axios": "^0.25.0", + "joi": "^17.6.0", + "lodash": "^4.17.21", + "minimist": "^1.2.5", + "rxjs": "^7.5.4" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/watchpack": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", + "integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/web-namespaces": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz", + "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "node_modules/webpack": { + "version": "5.72.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.72.1.tgz", + "integrity": "sha512-dXG5zXCLspQR4krZVR6QgajnZOjW2K/djHvdcRaDQvsjV9z9vaW6+ja5dZOYbqBBjF6kGXka/2ZyxNdc+8Jung==", + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^0.0.51", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.4.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.9.3", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.3.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz", + "integrity": "sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ==", + "dependencies": { + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "gzip-size": "^6.0.0", + "lodash": "^4.17.20", + "opener": "^1.5.2", + "sirv": "^1.0.7", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.1.tgz", + "integrity": "sha512-81EujCKkyles2wphtdrnPg/QqegC/AtqNH//mQkBYSMqwFVCQrxM6ktB2O/SPlZy7LqeEfTbV3cZARGQz6umhg==", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.1", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/webpack-dev-middleware/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.9.0.tgz", + "integrity": "sha512-+Nlb39iQSOSsFv0lWUuUTim3jDQO8nhK3E68f//J2r5rIcp4lULHXz2oZ0UVdEeWXEh5lSzYUlzarZhDAeAVQw==", + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.1", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^1.6.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.0.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.21", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.4.2" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.6.0.tgz", + "integrity": "sha512-AzmM3aH3gk0aX7/rZLYvjdvZooofDu3fFOzGqcSnQ1tOcTWwhM/o+q++E8mAyVVIyUdajrkzWUGftaVSDLn1bw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.8.0.tgz", + "integrity": "sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==", + "dependencies": { + "clone-deep": "^4.0.1", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dependencies": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack/node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpackbar": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-5.0.2.tgz", + "integrity": "sha512-BmFJo7veBDgQzfWXl/wwYXr/VFus0614qZ8i9znqcl9fnEdiVkdbi0TedLQ6xAK92HZHDJ0QmyQ0fmuZPAgCYQ==", + "dependencies": { + "chalk": "^4.1.0", + "consola": "^2.15.3", + "pretty-time": "^1.1.0", + "std-env": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "webpack": "3 || 4 || 5" + } + }, + "node_modules/webpackbar/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/webpackbar/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/webpackbar/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/webpackbar/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/webpackbar/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/webpackbar/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wildcard": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", + "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==" + }, + "node_modules/wrap-ansi": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.0.1.tgz", + "integrity": "sha512-QFF+ufAqhoYHvoHdajT/Po7KoXVBPXS2bgjIam5isfWJPfIOnQZ50JtUiVvCv/sjgacf3yRrt2ZKUZ/V4itN4g==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.1.0.tgz", + "integrity": "sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", + "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", + "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + }, + "dependencies": { + "@algolia/autocomplete-core": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.5.2.tgz", + "integrity": "sha512-DY0bhyczFSS1b/CqJlTE/nQRtnTAHl6IemIkBy0nEWnhDzRDdtdx4p5Uuk3vwAFxwEEgi1WqKwgSSMx6DpNL4A==", + "requires": { + "@algolia/autocomplete-shared": "1.5.2" + } + }, + "@algolia/autocomplete-preset-algolia": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.5.2.tgz", + "integrity": "sha512-3MRYnYQFJyovANzSX2CToS6/5cfVjbLLqFsZTKcvF3abhQzxbqwwaMBlJtt620uBUOeMzhdfasKhCc40+RHiZw==", + "requires": { + "@algolia/autocomplete-shared": "1.5.2" + } + }, + "@algolia/autocomplete-shared": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.5.2.tgz", + "integrity": "sha512-ylQAYv5H0YKMfHgVWX0j0NmL8XBcAeeeVQUmppnnMtzDbDnca6CzhKj3Q8eF9cHCgcdTDdb5K+3aKyGWA0obug==" + }, + "@algolia/cache-browser-local-storage": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.13.0.tgz", + "integrity": "sha512-nj1vHRZauTqP/bluwkRIgEADEimqojJgoTRCel5f6q8WCa9Y8QeI4bpDQP28FoeKnDRYa3J5CauDlN466jqRhg==", + "requires": { + "@algolia/cache-common": "4.13.0" + } + }, + "@algolia/cache-common": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.13.0.tgz", + "integrity": "sha512-f9mdZjskCui/dA/fA/5a+6hZ7xnHaaZI5tM/Rw9X8rRB39SUlF/+o3P47onZ33n/AwkpSbi5QOyhs16wHd55kA==" + }, + "@algolia/cache-in-memory": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.13.0.tgz", + "integrity": "sha512-hHdc+ahPiMM92CQMljmObE75laYzNFYLrNOu0Q3/eyvubZZRtY2SUsEEgyUEyzXruNdzrkcDxFYa7YpWBJYHAg==", + "requires": { + "@algolia/cache-common": "4.13.0" + } + }, + "@algolia/client-account": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.13.0.tgz", + "integrity": "sha512-FzFqFt9b0g/LKszBDoEsW+dVBuUe1K3scp2Yf7q6pgHWM1WqyqUlARwVpLxqyc+LoyJkTxQftOKjyFUqddnPKA==", + "requires": { + "@algolia/client-common": "4.13.0", + "@algolia/client-search": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "@algolia/client-analytics": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.13.0.tgz", + "integrity": "sha512-klmnoq2FIiiMHImkzOm+cGxqRLLu9CMHqFhbgSy9wtXZrqb8BBUIUE2VyBe7azzv1wKcxZV2RUyNOMpFqmnRZA==", + "requires": { + "@algolia/client-common": "4.13.0", + "@algolia/client-search": "4.13.0", + "@algolia/requester-common": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "@algolia/client-common": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.13.0.tgz", + "integrity": "sha512-GoXfTp0kVcbgfSXOjfrxx+slSipMqGO9WnNWgeMmru5Ra09MDjrcdunsiiuzF0wua6INbIpBQFTC2Mi5lUNqGA==", + "requires": { + "@algolia/requester-common": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "@algolia/client-personalization": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.13.0.tgz", + "integrity": "sha512-KneLz2WaehJmNfdr5yt2HQETpLaCYagRdWwIwkTqRVFCv4DxRQ2ChPVW9jeTj4YfAAhfzE6F8hn7wkQ/Jfj6ZA==", + "requires": { + "@algolia/client-common": "4.13.0", + "@algolia/requester-common": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "@algolia/client-search": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.13.0.tgz", + "integrity": "sha512-blgCKYbZh1NgJWzeGf+caKE32mo3j54NprOf0LZVCubQb3Kx37tk1Hc8SDs9bCAE8hUvf3cazMPIg7wscSxspA==", + "requires": { + "@algolia/client-common": "4.13.0", + "@algolia/requester-common": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "@algolia/events": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", + "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" + }, + "@algolia/logger-common": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.13.0.tgz", + "integrity": "sha512-8yqXk7rMtmQJ9wZiHOt/6d4/JDEg5VCk83gJ39I+X/pwUPzIsbKy9QiK4uJ3aJELKyoIiDT1hpYVt+5ia+94IA==" + }, + "@algolia/logger-console": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.13.0.tgz", + "integrity": "sha512-YepRg7w2/87L0vSXRfMND6VJ5d6699sFJBRWzZPOlek2p5fLxxK7O0VncYuc/IbVHEgeApvgXx0WgCEa38GVuQ==", + "requires": { + "@algolia/logger-common": "4.13.0" + } + }, + "@algolia/requester-browser-xhr": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.13.0.tgz", + "integrity": "sha512-Dj+bnoWR5MotrnjblzGKZ2kCdQi2cK/VzPURPnE616NU/il7Ypy6U6DLGZ/ZYz+tnwPa0yypNf21uqt84fOgrg==", + "requires": { + "@algolia/requester-common": "4.13.0" + } + }, + "@algolia/requester-common": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.13.0.tgz", + "integrity": "sha512-BRTDj53ecK+gn7ugukDWOOcBRul59C4NblCHqj4Zm5msd5UnHFjd/sGX+RLOEoFMhetILAnmg6wMrRrQVac9vw==" + }, + "@algolia/requester-node-http": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.13.0.tgz", + "integrity": "sha512-9b+3O4QFU4azLhGMrZAr/uZPydvzOR4aEZfSL8ZrpLZ7fbbqTO0S/5EVko+QIgglRAtVwxvf8UJ1wzTD2jvKxQ==", + "requires": { + "@algolia/requester-common": "4.13.0" + } + }, + "@algolia/transporter": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.13.0.tgz", + "integrity": "sha512-8tSQYE+ykQENAdeZdofvtkOr5uJ9VcQSWgRhQ9h01AehtBIPAczk/b2CLrMsw5yQZziLs5cZ3pJ3478yI+urhA==", + "requires": { + "@algolia/cache-common": "4.13.0", + "@algolia/logger-common": "4.13.0", + "@algolia/requester-common": "4.13.0" + } + }, + "@ampproject/remapping": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "requires": { + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/compat-data": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.10.tgz", + "integrity": "sha512-GZt/TCsG70Ms19gfZO1tM4CVnXsPgEPBCpJu+Qz3L0LUDsY5nZqFZglIoPC1kIYOtNBZlrnFT+klg12vFGZXrw==" + }, + "@babel/core": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.10.tgz", + "integrity": "sha512-liKoppandF3ZcBnIYFjfSDHZLKdLHGJRkoWtG8zQyGJBQfIYobpnVGI5+pLBNtS6psFLDzyq8+h5HiVljW9PNA==", + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-compilation-targets": "^7.17.10", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.10", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.10", + "@babel/types": "^7.17.10", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "@babel/generator": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.10.tgz", + "integrity": "sha512-46MJZZo9y3o4kmhBVc7zW7i8dtR1oIK/sdO5NcfcZRhTGYi+KKJRtHNgsU6c4VUcJmUNV/LQdebD/9Dlv4K+Tg==", + "requires": { + "@babel/types": "^7.17.10", + "@jridgewell/gen-mapping": "^0.1.0", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", + "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.16.7.tgz", + "integrity": "sha512-C6FdbRaxYjwVu/geKW4ZeQ0Q31AftgRcdSnZ5/jsH6BzCJbtvXvhpfkbkThYSuutZA7nCXpPR6AD9zd1dprMkA==", + "requires": { + "@babel/helper-explode-assignable-expression": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.10.tgz", + "integrity": "sha512-gh3RxjWbauw/dFiU/7whjd0qN9K6nPJMqe6+Er7rOavFh0CQUSwhAE3IcTho2rywPJFxej6TUUHDkWcYI6gGqQ==", + "requires": { + "@babel/compat-data": "^7.17.10", + "@babel/helper-validator-option": "^7.16.7", + "browserslist": "^4.20.2", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.9.tgz", + "integrity": "sha512-kUjip3gruz6AJKOq5i3nC6CoCEEF/oHH3cp6tOZhB+IyyyPyW0g1Gfsxn3mkk6S08pIA2y8GQh609v9G/5sHVQ==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-member-expression-to-functions": "^7.17.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.17.0.tgz", + "integrity": "sha512-awO2So99wG6KnlE+TPs6rn83gCz5WlEePJDTnLEqbchMVrBeAujURVphRdigsk094VhvZehFoNOihSlcBjwsXA==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "regexpu-core": "^5.0.1" + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.1.tgz", + "integrity": "sha512-J9hGMpJQmtWmj46B3kBHmL38UhJGhYX7eqkcq+2gsstyYt341HmPeWspihX43yVRA0mS+8GGk2Gckc7bY/HCmA==", + "requires": { + "@babel/helper-compilation-targets": "^7.13.0", + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/traverse": "^7.13.0", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.16.7.tgz", + "integrity": "sha512-KyUenhWMC8VrxzkGP0Jizjo4/Zx+1nNZhgocs+gLzyZyB8SHidhoq9KK/8Ato4anhwsivfkBLftky7gvzbZMtQ==", + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-function-name": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", + "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", + "requires": { + "@babel/template": "^7.16.7", + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.17.7.tgz", + "integrity": "sha512-thxXgnQ8qQ11W2wVUObIqDL4p148VMxkt5T/qpN5k2fboRyzFGFmKsTGViquyM5QHKUy48OZoca8kw4ajaDPyw==", + "requires": { + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-module-imports": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", + "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-module-transforms": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", + "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.3", + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz", + "integrity": "sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==", + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==" + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.16.8.tgz", + "integrity": "sha512-fm0gH7Flb8H51LqJHy3HJ3wnE1+qtYR2A99K06ahwrawLdOFsCEWjZOrYricXJHoPSudNKxrMBUPEIPxiIIvBw==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-wrap-function": "^7.16.8", + "@babel/types": "^7.16.8" + } + }, + "@babel/helper-replace-supers": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz", + "integrity": "sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw==", + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-member-expression-to-functions": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/traverse": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-simple-access": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", + "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", + "requires": { + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.16.0.tgz", + "integrity": "sha512-+il1gTy0oHwUsBQZyJvukbB4vPMdcYBrFHa0Uc4AizLxbq6BOYC51Rv4tWocX9BLBDLZ4kc6qUFpQ6HRgL+3zw==", + "requires": { + "@babel/types": "^7.16.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==" + }, + "@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==" + }, + "@babel/helper-wrap-function": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.16.8.tgz", + "integrity": "sha512-8RpyRVIAW1RcDDGTA+GpPAwV22wXCfKOoM9bet6TLkGIFTkRQSkH1nMQ5Yet4MpoXe1ZwHPVtNasc2w0uZMqnw==", + "requires": { + "@babel/helper-function-name": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.16.8", + "@babel/types": "^7.16.8" + } + }, + "@babel/helpers": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", + "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", + "requires": { + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0" + } + }, + "@babel/highlight": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", + "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.10.tgz", + "integrity": "sha512-n2Q6i+fnJqzOaq2VkdXxy2TCPCWQZHiCo0XqmrCvDWcZQKRyZzYi4Z0yxlBuN0w+r2ZHmre+Q087DSrw3pbJDQ==" + }, + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.16.7.tgz", + "integrity": "sha512-anv/DObl7waiGEnC24O9zqL0pSuI9hljihqiDuFHC8d7/bjr/4RLGPWuc8rYOff/QPzbEPSkzG8wGG9aDuhHRg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.16.7.tgz", + "integrity": "sha512-di8vUHRdf+4aJ7ltXhaDbPoszdkh59AQtJM5soLsuHpQJdFQZOA4uGj0V2u/CZ8bJ/u8ULDL5yq6FO/bCXnKHw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.7" + } + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.16.8.tgz", + "integrity": "sha512-71YHIvMuiuqWJQkebWJtdhQTfd4Q4mF76q2IX37uZPkG9+olBxsX+rH1vkhFto4UeJZ9dPY2s+mDvhDm1u2BGQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-remap-async-to-generator": "^7.16.8", + "@babel/plugin-syntax-async-generators": "^7.8.4" + } + }, + "@babel/plugin-proposal-class-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.16.7.tgz", + "integrity": "sha512-IobU0Xme31ewjYOShSIqd/ZGM/r/cuOz2z0MDbNrhF5FW+ZVgi0f2lyeoj9KFPDOAqsYxmLWZte1WOwlvY9aww==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-proposal-class-static-block": { + "version": "7.17.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.17.6.tgz", + "integrity": "sha512-X/tididvL2zbs7jZCeeRJ8167U/+Ac135AM6jCAx6gYXDUviZV5Ku9UDvWS2NCuWlFjIRXklYhwo6HhAC7ETnA==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.17.6", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.16.7.tgz", + "integrity": "sha512-I8SW9Ho3/8DRSdmDdH3gORdyUuYnk1m4cMxUAdu5oy4n3OfN8flDEH+d60iG7dUfi0KkYwSvoalHzzdRzpWHTg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + } + }, + "@babel/plugin-proposal-export-namespace-from": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.16.7.tgz", + "integrity": "sha512-ZxdtqDXLRGBL64ocZcs7ovt71L3jhC1RGSyR996svrCi3PYqHNkb3SwPJCs8RIzD86s+WPpt2S73+EHCGO+NUA==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.16.7.tgz", + "integrity": "sha512-lNZ3EEggsGY78JavgbHsK9u5P3pQaW7k4axlgFLYkMd7UBsiNahCITShLjNQschPyjtO6dADrL24757IdhBrsQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-json-strings": "^7.8.3" + } + }, + "@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.16.7.tgz", + "integrity": "sha512-K3XzyZJGQCr00+EtYtrDjmwX7o7PLK6U9bi1nCwkQioRFVUv6dJoxbQjtWVtP+bCPy82bONBKG8NPyQ4+i6yjg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.16.7.tgz", + "integrity": "sha512-aUOrYU3EVtjf62jQrCj63pYZ7k6vns2h/DQvHPWGmsJRYzWXZ6/AsfgpiRy6XiuIDADhJzP2Q9MwSMKauBQ+UQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + } + }, + "@babel/plugin-proposal-numeric-separator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.16.7.tgz", + "integrity": "sha512-vQgPMknOIgiuVqbokToyXbkY/OmmjAzr/0lhSIbG/KmnzXPGwW/AdhdKpi+O4X/VkWiWjnkKOBiqJrTaC98VKw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.17.3.tgz", + "integrity": "sha512-yuL5iQA/TbZn+RGAfxQXfi7CNLmKi1f8zInn4IgobuCWcAb7i+zj4TYzQ9l8cEzVyJ89PDGuqxK1xZpUDISesw==", + "requires": { + "@babel/compat-data": "^7.17.0", + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.16.7" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.16.7.tgz", + "integrity": "sha512-eMOH/L4OvWSZAE1VkHbr1vckLG1WUcHGJSLqqQwl2GaUqG6QjddvrOaTUMNYiv77H5IKPMZ9U9P7EaHwvAShfA==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.16.7.tgz", + "integrity": "sha512-eC3xy+ZrUcBtP7x+sq62Q/HYd674pPTb/77XZMb5wbDPGWIdUbSr4Agr052+zaUPSb+gGRnjxXfKFvx5iMJ+DA==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + } + }, + "@babel/plugin-proposal-private-methods": { + "version": "7.16.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.16.11.tgz", + "integrity": "sha512-F/2uAkPlXDr8+BHpZvo19w3hLFKge+k75XUprE6jaqKxjGkSYcK+4c+bup5PdW/7W/Rpjwql7FTVEDW+fRAQsw==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.10", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-proposal-private-property-in-object": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.16.7.tgz", + "integrity": "sha512-rMQkjcOFbm+ufe3bTZLyOfsOUOxyvLXZJCTARhJr+8UMSoZmqTe1K1BgkFcrW37rAchWg57yI69ORxiWvUINuQ==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.16.7.tgz", + "integrity": "sha512-QRK0YI/40VLhNVGIjRNAAQkEHws0cswSdFFjpFyt943YmJIU1da9uW63Iu6NFV6CxTZW5eTDCrwZUstBWgp/Rg==", + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", + "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.17.10.tgz", + "integrity": "sha512-xJefea1DWXW09pW4Tm9bjwVlPDyYA2it3fWlmEjpYz6alPvTUjL0EOzNzI/FEOyI3r4/J7uVH5UqKgl1TQ5hqQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.16.7.tgz", + "integrity": "sha512-9ffkFFMbvzTvv+7dTp/66xvZAWASuPD5Tl9LK3Z9vhOmANo6j94rik+5YMBt4CwHVMWLWpMsriIc2zsa3WW3xQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.16.8.tgz", + "integrity": "sha512-MtmUmTJQHCnyJVrScNzNlofQJ3dLFuobYn3mwOTKHnSCMtbNsqvF71GQmJfFjdrXSsAA7iysFmYWw4bXZ20hOg==", + "requires": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-remap-async-to-generator": "^7.16.8" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.16.7.tgz", + "integrity": "sha512-JUuzlzmF40Z9cXyytcbZEZKckgrQzChbQJw/5PuEHYeqzCsvebDx0K0jWnIIVcmmDOAVctCgnYs0pMcrYj2zJg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.16.7.tgz", + "integrity": "sha512-ObZev2nxVAYA4bhyusELdo9hb3H+A56bxH3FZMbEImZFiEDYVHXQSJ1hQKFlDnlt8G9bBrCZ5ZpURZUrV4G5qQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.16.7.tgz", + "integrity": "sha512-WY7og38SFAGYRe64BrjKf8OrE6ulEHtr5jEYaZMwox9KebgqPi67Zqz8K53EKk1fFEJgm96r32rkKZ3qA2nCWQ==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "globals": "^11.1.0" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.16.7.tgz", + "integrity": "sha512-gN72G9bcmenVILj//sv1zLNaPyYcOzUho2lIJBMh/iakJ9ygCo/hEF9cpGb61SCMEDxbbyBoVQxrt+bWKu5KGw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.17.7.tgz", + "integrity": "sha512-XVh0r5yq9sLR4vZ6eVZe8FKfIcSgaTBxVBRSYokRj2qksf6QerYnTxz9/GTuKTH/n/HwLP7t6gtlybHetJ/6hQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.16.7.tgz", + "integrity": "sha512-Lyttaao2SjZF6Pf4vk1dVKv8YypMpomAbygW+mU5cYP3S5cWTfCJjG8xV6CFdzGFlfWK81IjL9viiTvpb6G7gQ==", + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.16.7.tgz", + "integrity": "sha512-03DvpbRfvWIXyK0/6QiR1KMTWeT6OcQ7tbhjrXyFS02kjuX/mu5Bvnh5SDSWHxyawit2g5aWhKwI86EE7GUnTw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.16.7.tgz", + "integrity": "sha512-8UYLSlyLgRixQvlYH3J2ekXFHDFLQutdy7FfFAMm3CPZ6q9wHCwnUyiXpQCe3gVVnQlHc5nsuiEVziteRNTXEA==", + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.16.7.tgz", + "integrity": "sha512-/QZm9W92Ptpw7sjI9Nx1mbcsWz33+l8kuMIQnDwgQBG5s3fAfQvkRjQ7NqXhtNcKOnPkdICmUHyCaWW06HCsqg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.16.7.tgz", + "integrity": "sha512-SU/C68YVwTRxqWj5kgsbKINakGag0KTgq9f2iZEXdStoAbOzLHEBRYzImmA6yFo8YZhJVflvXmIHUO7GWHmxxA==", + "requires": { + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.16.7.tgz", + "integrity": "sha512-6tH8RTpTWI0s2sV6uq3e/C9wPo4PTqqZps4uF0kzQ9/xPLFQtipynvmT1g/dOfEJ+0EQsHhkQ/zyRId8J2b8zQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.16.7.tgz", + "integrity": "sha512-mBruRMbktKQwbxaJof32LT9KLy2f3gH+27a5XSuXo6h7R3vqltl0PgZ80C8ZMKw98Bf8bqt6BEVi3svOh2PzMw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.16.7.tgz", + "integrity": "sha512-KaaEtgBL7FKYwjJ/teH63oAmE3lP34N3kshz8mm4VMAw7U3PxjVwwUmxEFksbgsNUaO3wId9R2AVQYSEGRa2+g==", + "requires": { + "@babel/helper-module-transforms": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + }, + "dependencies": { + "babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "requires": { + "object.assign": "^4.1.0" + } + } + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.17.9.tgz", + "integrity": "sha512-2TBFd/r2I6VlYn0YRTz2JdazS+FoUuQ2rIFHoAxtyP/0G3D82SBLaRq9rnUkpqlLg03Byfl/+M32mpxjO6KaPw==", + "requires": { + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + }, + "dependencies": { + "babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "requires": { + "object.assign": "^4.1.0" + } + } + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.17.8.tgz", + "integrity": "sha512-39reIkMTUVagzgA5x88zDYXPCMT6lcaRKs1+S9K6NKBPErbgO/w/kP8GlNQTC87b412ZTlmNgr3k2JrWgHH+Bw==", + "requires": { + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + }, + "dependencies": { + "babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "requires": { + "object.assign": "^4.1.0" + } + } + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.16.7.tgz", + "integrity": "sha512-EMh7uolsC8O4xhudF2F6wedbSHm1HHZ0C6aJ7K67zcDNidMzVcxWdGr+htW9n21klm+bOn+Rx4CBsAntZd3rEQ==", + "requires": { + "@babel/helper-module-transforms": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.17.10.tgz", + "integrity": "sha512-v54O6yLaJySCs6mGzaVOUw9T967GnH38T6CQSAtnzdNPwu84l2qAjssKzo/WSO8Yi7NF+7ekm5cVbF/5qiIgNA==", + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.17.0" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.16.7.tgz", + "integrity": "sha512-xiLDzWNMfKoGOpc6t3U+etCE2yRnn3SM09BXqWPIZOBpL2gvVrBWUKnsJx0K/ADi5F5YC5f8APFfWrz25TdlGg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.16.7.tgz", + "integrity": "sha512-14J1feiQVWaGvRxj2WjyMuXS2jsBkgB3MdSN5HuC2G5nRspa5RK9COcs82Pwy5BuGcjb+fYaUj94mYcOj7rCvw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.16.7.tgz", + "integrity": "sha512-AT3MufQ7zZEhU2hwOA11axBnExW0Lszu4RL/tAlUJBuNoRak+wehQW8h6KcXOcgjY42fHtDxswuMhMjFEuv/aw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.16.7.tgz", + "integrity": "sha512-z4FGr9NMGdoIl1RqavCqGG+ZuYjfZ/hkCIeuH6Do7tXmSm0ls11nYVSJqFEUOSJbDab5wC6lRE/w6YjVcr6Hqw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-constant-elements": { + "version": "7.17.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.17.6.tgz", + "integrity": "sha512-OBv9VkyyKtsHZiHLoSfCn+h6yU7YKX8nrs32xUmOa1SRSk+t03FosB6fBZ0Yz4BpD1WV7l73Nsad+2Tz7APpqw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.16.7.tgz", + "integrity": "sha512-qgIg8BcZgd0G/Cz916D5+9kqX0c7nPZyXaP8R2tLNN5tkyIZdG5fEwBrxwplzSnjC1jvQmyMNVwUCZPcbGY7Pg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", + "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-jsx": "^7.16.7", + "@babel/types": "^7.17.0" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", + "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", + "requires": { + "@babel/plugin-transform-react-jsx": "^7.16.7" + } + }, + "@babel/plugin-transform-react-pure-annotations": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.16.7.tgz", + "integrity": "sha512-hs71ToC97k3QWxswh2ElzMFABXHvGiJ01IB1TbYQDGeWRKWz/MPUTh5jGExdHvosYKpnJW5Pm3S4+TA3FyX+GA==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.17.9.tgz", + "integrity": "sha512-Lc2TfbxR1HOyn/c6b4Y/b6NHoTb67n/IoWLxTu4kC7h4KQnWlhCq2S8Tx0t2SVvv5Uu87Hs+6JEJ5kt2tYGylQ==", + "requires": { + "regenerator-transform": "^0.15.0" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.16.7.tgz", + "integrity": "sha512-KQzzDnZ9hWQBjwi5lpY5v9shmm6IVG0U9pB18zvMu2i4H90xpT4gmqwPYsn8rObiadYe2M0gmgsiOIF5A/2rtg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-runtime": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.17.10.tgz", + "integrity": "sha512-6jrMilUAJhktTr56kACL8LnWC5hx3Lf27BS0R0DSyW/OoJfb/iTHeE96V3b1dgKG3FSFdd/0culnYWMkjcKCig==", + "requires": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "babel-plugin-polyfill-corejs2": "^0.3.0", + "babel-plugin-polyfill-corejs3": "^0.5.0", + "babel-plugin-polyfill-regenerator": "^0.3.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.16.7.tgz", + "integrity": "sha512-hah2+FEnoRoATdIb05IOXf+4GzXYTq75TVhIn1PewihbpyrNWUt2JbudKQOETWw6QpLe+AIUpJ5MVLYTQbeeUg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.16.7.tgz", + "integrity": "sha512-+pjJpgAngb53L0iaA5gU/1MLXJIfXcYepLgXB3esVRf4fqmj8f2cxM3/FKaHsZms08hFQJkFccEWuIpm429TXg==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.16.7.tgz", + "integrity": "sha512-NJa0Bd/87QV5NZZzTuZG5BPJjLYadeSZ9fO6oOUoL4iQx+9EEuw/eEM92SrsT19Yc2jgB1u1hsjqDtH02c3Drw==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.16.7.tgz", + "integrity": "sha512-VwbkDDUeenlIjmfNeDX/V0aWrQH2QiVyJtwymVQSzItFDTpxfyJh3EVaQiS0rIN/CqbLGr0VcGmuwyTdZtdIsA==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.16.7.tgz", + "integrity": "sha512-p2rOixCKRJzpg9JB4gjnG4gjWkWa89ZoYUnl9snJ1cWIcTH/hvxZqfO+WjG6T8DRBpctEol5jw1O5rA8gkCokQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-typescript": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", + "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-typescript": "^7.16.7" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz", + "integrity": "sha512-TAV5IGahIz3yZ9/Hfv35TV2xEm+kaBDaZQCn2S/hG9/CZ0DktxJv9eKfPc7yYCvOYR4JGx1h8C+jcSOvgaaI/Q==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.16.7.tgz", + "integrity": "sha512-oC5tYYKw56HO75KZVLQ+R/Nl3Hro9kf8iG0hXoaHP7tjAyCpvqBiSNe6vGrZni1Z6MggmUOC6A7VP7AVmw225Q==", + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/preset-env": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.17.10.tgz", + "integrity": "sha512-YNgyBHZQpeoBSRBg0xixsZzfT58Ze1iZrajvv0lJc70qDDGuGfonEnMGfWeSY0mQ3JTuCWFbMkzFRVafOyJx4g==", + "requires": { + "@babel/compat-data": "^7.17.10", + "@babel/helper-compilation-targets": "^7.17.10", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.16.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.16.7", + "@babel/plugin-proposal-async-generator-functions": "^7.16.8", + "@babel/plugin-proposal-class-properties": "^7.16.7", + "@babel/plugin-proposal-class-static-block": "^7.17.6", + "@babel/plugin-proposal-dynamic-import": "^7.16.7", + "@babel/plugin-proposal-export-namespace-from": "^7.16.7", + "@babel/plugin-proposal-json-strings": "^7.16.7", + "@babel/plugin-proposal-logical-assignment-operators": "^7.16.7", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7", + "@babel/plugin-proposal-numeric-separator": "^7.16.7", + "@babel/plugin-proposal-object-rest-spread": "^7.17.3", + "@babel/plugin-proposal-optional-catch-binding": "^7.16.7", + "@babel/plugin-proposal-optional-chaining": "^7.16.7", + "@babel/plugin-proposal-private-methods": "^7.16.11", + "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "@babel/plugin-proposal-unicode-property-regex": "^7.16.7", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.16.7", + "@babel/plugin-transform-async-to-generator": "^7.16.8", + "@babel/plugin-transform-block-scoped-functions": "^7.16.7", + "@babel/plugin-transform-block-scoping": "^7.16.7", + "@babel/plugin-transform-classes": "^7.16.7", + "@babel/plugin-transform-computed-properties": "^7.16.7", + "@babel/plugin-transform-destructuring": "^7.17.7", + "@babel/plugin-transform-dotall-regex": "^7.16.7", + "@babel/plugin-transform-duplicate-keys": "^7.16.7", + "@babel/plugin-transform-exponentiation-operator": "^7.16.7", + "@babel/plugin-transform-for-of": "^7.16.7", + "@babel/plugin-transform-function-name": "^7.16.7", + "@babel/plugin-transform-literals": "^7.16.7", + "@babel/plugin-transform-member-expression-literals": "^7.16.7", + "@babel/plugin-transform-modules-amd": "^7.16.7", + "@babel/plugin-transform-modules-commonjs": "^7.17.9", + "@babel/plugin-transform-modules-systemjs": "^7.17.8", + "@babel/plugin-transform-modules-umd": "^7.16.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.17.10", + "@babel/plugin-transform-new-target": "^7.16.7", + "@babel/plugin-transform-object-super": "^7.16.7", + "@babel/plugin-transform-parameters": "^7.16.7", + "@babel/plugin-transform-property-literals": "^7.16.7", + "@babel/plugin-transform-regenerator": "^7.17.9", + "@babel/plugin-transform-reserved-words": "^7.16.7", + "@babel/plugin-transform-shorthand-properties": "^7.16.7", + "@babel/plugin-transform-spread": "^7.16.7", + "@babel/plugin-transform-sticky-regex": "^7.16.7", + "@babel/plugin-transform-template-literals": "^7.16.7", + "@babel/plugin-transform-typeof-symbol": "^7.16.7", + "@babel/plugin-transform-unicode-escapes": "^7.16.7", + "@babel/plugin-transform-unicode-regex": "^7.16.7", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.17.10", + "babel-plugin-polyfill-corejs2": "^0.3.0", + "babel-plugin-polyfill-corejs3": "^0.5.0", + "babel-plugin-polyfill-regenerator": "^0.3.0", + "core-js-compat": "^3.22.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "@babel/preset-modules": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/preset-react": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.16.7.tgz", + "integrity": "sha512-fWpyI8UM/HE6DfPBzD8LnhQ/OcH8AgTaqcqP2nGOXEUV+VKBR5JRN9hCk9ai+zQQ57vtm9oWeXguBCPNUjytgA==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-react-display-name": "^7.16.7", + "@babel/plugin-transform-react-jsx": "^7.16.7", + "@babel/plugin-transform-react-jsx-development": "^7.16.7", + "@babel/plugin-transform-react-pure-annotations": "^7.16.7" + } + }, + "@babel/preset-typescript": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", + "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-typescript": "^7.16.7" + } + }, + "@babel/runtime": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", + "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@babel/runtime-corejs3": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.9.tgz", + "integrity": "sha512-WxYHHUWF2uZ7Hp1K+D1xQgbgkGUfA+5UPOegEXGt2Y5SMog/rYCVaifLZDbw8UkNXozEqqrZTy6bglL7xTaCOw==", + "requires": { + "core-js-pure": "^3.20.2", + "regenerator-runtime": "^0.13.4" + } + }, + "@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/traverse": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.10.tgz", + "integrity": "sha512-VmbrTHQteIdUUQNTb+zE12SHS/xQVIShmBPhlNP12hD5poF2pbITW1Z4172d03HegaQWhLffdkRJYtAzp0AGcw==", + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.10", + "@babel/types": "^7.17.10", + "debug": "^4.1.0", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.10.tgz", + "integrity": "sha512-9O26jG0mBYfGkUYCYZRnBwbVLd1UZOICEr2Em6InB6jVfsAv1GKgwXHmrSg+WFWDmeKTA6vyTZiN8tCSM5Oo3A==", + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "optional": true + }, + "@docsearch/css": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.0.0.tgz", + "integrity": "sha512-1kkV7tkAsiuEd0shunYRByKJe3xQDG2q7wYg24SOw1nV9/2lwEd4WrUYRJC/ukGTl2/kHeFxsaUvtiOy0y6fFA==" + }, + "@docsearch/react": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.0.0.tgz", + "integrity": "sha512-yhMacqS6TVQYoBh/o603zszIb5Bl8MIXuOc6Vy617I74pirisDzzcNh0NEaYQt50fVVR3khUbeEhUEWEWipESg==", + "requires": { + "@algolia/autocomplete-core": "1.5.2", + "@algolia/autocomplete-preset-algolia": "1.5.2", + "@docsearch/css": "3.0.0", + "algoliasearch": "^4.0.0" + } + }, + "@docusaurus/core": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-0.0.0-4999.tgz", + "integrity": "sha512-LoZAyzKIIKRux9axoI674ZFv3aPZSHzYcTGWp6+Mn9OYNLgtiZyNdqqvqvEl2mEe9o4R0umu+13mnxnNT9TyfQ==", + "requires": { + "@babel/core": "^7.17.10", + "@babel/generator": "^7.17.10", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.17.10", + "@babel/preset-env": "^7.17.10", + "@babel/preset-react": "^7.16.7", + "@babel/preset-typescript": "^7.16.7", + "@babel/runtime": "^7.17.9", + "@babel/runtime-corejs3": "^7.17.9", + "@babel/traverse": "^7.17.10", + "@docusaurus/cssnano-preset": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/mdx-loader": "0.0.0-4999", + "@docusaurus/react-loadable": "5.5.2", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-common": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "@slorber/static-site-generator-webpack-plugin": "^4.0.4", + "@svgr/webpack": "^6.2.1", + "autoprefixer": "^10.4.7", + "babel-loader": "^8.2.5", + "babel-plugin-dynamic-import-node": "2.3.0", + "boxen": "^6.2.1", + "chokidar": "^3.5.3", + "clean-css": "^5.3.0", + "cli-table3": "^0.6.2", + "combine-promises": "^1.1.0", + "commander": "^5.1.0", + "copy-webpack-plugin": "^10.2.4", + "core-js": "^3.22.5", + "css-loader": "^6.7.1", + "css-minimizer-webpack-plugin": "^3.4.1", + "cssnano": "^5.1.7", + "del": "^6.0.0", + "detect-port": "^1.3.0", + "escape-html": "^1.0.3", + "eta": "^1.12.3", + "file-loader": "^6.2.0", + "fs-extra": "^10.1.0", + "html-minifier-terser": "^6.1.0", + "html-tags": "^3.2.0", + "html-webpack-plugin": "^5.5.0", + "import-fresh": "^3.3.0", + "leven": "^3.1.0", + "lodash": "^4.17.21", + "mini-css-extract-plugin": "^2.6.0", + "postcss": "^8.4.13", + "postcss-loader": "^6.2.1", + "prompts": "^2.4.2", + "react-dev-utils": "^12.0.1", + "react-helmet-async": "^1.3.0", + "react-loadable": "npm:@docusaurus/react-loadable@5.5.2", + "react-loadable-ssr-addon-v5-slorber": "^1.0.1", + "react-router": "^5.2.0", + "react-router-config": "^5.1.1", + "react-router-dom": "^5.2.0", + "remark-admonitions": "^1.2.1", + "rtl-detect": "^1.0.4", + "semver": "^7.3.7", + "serve-handler": "^6.1.3", + "shelljs": "^0.8.5", + "terser-webpack-plugin": "^5.3.1", + "tslib": "^2.4.0", + "update-notifier": "^5.1.0", + "url-loader": "^4.1.1", + "wait-on": "^6.0.1", + "webpack": "^5.72.1", + "webpack-bundle-analyzer": "^4.5.0", + "webpack-dev-server": "^4.9.0", + "webpack-merge": "^5.8.0", + "webpackbar": "^5.0.2" + } + }, + "@docusaurus/cssnano-preset": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-0.0.0-4999.tgz", + "integrity": "sha512-rLnqtmSs7MoRYOO1+QnoB0tbpKtX3fA5oVHxdGrOak+F8JgVLbzgBHhvtr1dYN6dmbZuqSmBlbuScavAWeiLlw==", + "requires": { + "cssnano-preset-advanced": "^5.3.3", + "postcss": "^8.4.13", + "postcss-sort-media-queries": "^4.2.1" + } + }, + "@docusaurus/logger": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-0.0.0-4999.tgz", + "integrity": "sha512-tpFzK0O2mRTER8Jmi6caI0R5sMXnTdj7e/Lo+vxclv8bWcE2+CFnZTIXNn/+n1euQd4f6vBwubfRnNJyP9uQ9g==", + "requires": { + "chalk": "^4.1.2", + "tslib": "^2.4.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "@docusaurus/mdx-loader": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-0.0.0-4999.tgz", + "integrity": "sha512-Emhdmf20Qi633vUoe2h3g5qFffvuYAahlYEaW6dxMF6T5NPw2Jr41oWdbjsPhSKOX6VRtDX6RmQUaUVVQWzc3A==", + "requires": { + "@babel/parser": "^7.17.10", + "@babel/traverse": "^7.17.10", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@mdx-js/mdx": "^1.6.22", + "escape-html": "^1.0.3", + "file-loader": "^6.2.0", + "fs-extra": "^10.1.0", + "image-size": "^1.0.1", + "mdast-util-to-string": "^2.0.0", + "remark-emoji": "^2.2.0", + "stringify-object": "^3.3.0", + "tslib": "^2.4.0", + "unist-util-visit": "^2.0.3", + "url-loader": "^4.1.1", + "webpack": "^5.72.1" + } + }, + "@docusaurus/module-type-aliases": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-0.0.0-4999.tgz", + "integrity": "sha512-mR2qwLff5qv9Nv47XmqnpMuHE9WwTGfeGpYNzLG5j3S4zLWJjAFzWPhudqGe0FCjySkAHHFf2EwikRUnHsJ0xQ==", + "requires": { + "@docusaurus/types": "0.0.0-4999", + "@types/react": "*", + "@types/react-router-config": "*", + "@types/react-router-dom": "*", + "react-helmet-async": "*" + } + }, + "@docusaurus/plugin-content-blog": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-0.0.0-4999.tgz", + "integrity": "sha512-1jv2HnBWxc/wp35A7H2BGTzUCmwf9iSxSioNvQe7TS0UpRo2IiO2CRC8a0GKx37gKJBWUl3JBvOYwBW6ZS3yDg==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/mdx-loader": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-common": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "cheerio": "^1.0.0-rc.10", + "feed": "^4.2.2", + "fs-extra": "^10.1.0", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "remark-admonitions": "^1.2.1", + "tslib": "^2.4.0", + "unist-util-visit": "^2.0.3", + "utility-types": "^3.10.0", + "webpack": "^5.72.1" + } + }, + "@docusaurus/plugin-content-docs": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-0.0.0-4999.tgz", + "integrity": "sha512-cv3fXhal2SjUNzHWeWiH6JGczdqKHLZ4ZFpFjNV7IYfoHAvC8pU31RfdLg7X4OxUG32lFDjQk132l/aTHEdmTQ==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/mdx-loader": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "combine-promises": "^1.1.0", + "fs-extra": "^10.1.0", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "remark-admonitions": "^1.2.1", + "tslib": "^2.4.0", + "utility-types": "^3.10.0", + "webpack": "^5.72.1" + } + }, + "@docusaurus/plugin-content-pages": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-0.0.0-4999.tgz", + "integrity": "sha512-RVPm2pj5HPNyfUO3FFW3Uazrhz8xEiDUcxWkr0FtNh8oG1alTW6KAPotmO9j4t/NAw+fEb4PzyPumfvYoH2H3w==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/mdx-loader": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "fs-extra": "^10.1.0", + "remark-admonitions": "^1.2.1", + "tslib": "^2.4.0", + "webpack": "^5.72.1" + } + }, + "@docusaurus/plugin-debug": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-0.0.0-4999.tgz", + "integrity": "sha512-CDjqbo6nTlc7GoJn5y29iTLbIVREZh6CdXlKZGCb31TDAYOnazugzY73jv7avxBGGJRQDaHs6zKJhQj26+f9QA==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "fs-extra": "^10.1.0", + "react-json-view": "^1.21.3", + "tslib": "^2.4.0" + } + }, + "@docusaurus/plugin-google-analytics": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-0.0.0-4999.tgz", + "integrity": "sha512-YnLbqvebBSTBaBi3zqAR2hmp4thFm0yL84FQS8uMq5K+BKsLDuiA5kzgneldfJ4BEz8L/AFWU+aXFN3Ug5vt9w==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "tslib": "^2.4.0" + } + }, + "@docusaurus/plugin-google-gtag": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-0.0.0-4999.tgz", + "integrity": "sha512-AP9ioYmaSSLihOPWE5tRsmbL1NY33h9SrN1HQyUqhYm4wV2LTo31+qB+u8kxzts7/lqbcQJpazFc/PtQhj976w==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "tslib": "^2.4.0" + } + }, + "@docusaurus/plugin-sitemap": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-0.0.0-4999.tgz", + "integrity": "sha512-JyUNqOAPNyJjxLroE3QJQZL0045vKJz4G50MUUGBhgDlwEk+B8K0CTmcyef5jjFuRPSFRtJ23j3N1wzOAWfYOw==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-common": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "fs-extra": "^10.1.0", + "sitemap": "^7.1.1", + "tslib": "^2.4.0" + } + }, + "@docusaurus/preset-classic": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-0.0.0-4999.tgz", + "integrity": "sha512-adpxuIUW+lsXsNqv69ISJoEoW1B/A0rqavTL+zKfeKHB8LNdFINOPspwE9oJAaIAQuDB/vTjts6wQlyED/EKZg==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/plugin-content-blog": "0.0.0-4999", + "@docusaurus/plugin-content-docs": "0.0.0-4999", + "@docusaurus/plugin-content-pages": "0.0.0-4999", + "@docusaurus/plugin-debug": "0.0.0-4999", + "@docusaurus/plugin-google-analytics": "0.0.0-4999", + "@docusaurus/plugin-google-gtag": "0.0.0-4999", + "@docusaurus/plugin-sitemap": "0.0.0-4999", + "@docusaurus/theme-classic": "0.0.0-4999", + "@docusaurus/theme-common": "0.0.0-4999", + "@docusaurus/theme-search-algolia": "0.0.0-4999" + } + }, + "@docusaurus/react-loadable": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz", + "integrity": "sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==", + "requires": { + "@types/react": "*", + "prop-types": "^15.6.2" + } + }, + "@docusaurus/theme-classic": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-0.0.0-4999.tgz", + "integrity": "sha512-lX4bg8pAJfXzkaEclf5XCVV3/yIZaQy7v6Ow9SxXdxQHLU2gLmOoIdoe5g28YAIMHr7JnJu1J0SFO6cD6OTwsg==", + "requires": { + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/plugin-content-blog": "0.0.0-4999", + "@docusaurus/plugin-content-docs": "0.0.0-4999", + "@docusaurus/plugin-content-pages": "0.0.0-4999", + "@docusaurus/theme-common": "0.0.0-4999", + "@docusaurus/theme-translations": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-common": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "@mdx-js/react": "^1.6.22", + "clsx": "^1.1.1", + "copy-text-to-clipboard": "^3.0.1", + "infima": "0.2.0-alpha.39", + "lodash": "^4.17.21", + "nprogress": "^0.2.0", + "postcss": "^8.4.13", + "prism-react-renderer": "^1.3.1", + "prismjs": "^1.28.0", + "react-router-dom": "^5.2.0", + "rtlcss": "^3.5.0" + } + }, + "@docusaurus/theme-common": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-0.0.0-4999.tgz", + "integrity": "sha512-KsmrS0NxaZkeMBuHe1gZ47KxW/CEuZtN1x1T9Q+BRbCbKcaW26jv2pzLOy80hRtDg5QA6IcxsH52PzJE2aGN7A==", + "requires": { + "@docusaurus/module-type-aliases": "0.0.0-4999", + "@docusaurus/plugin-content-blog": "0.0.0-4999", + "@docusaurus/plugin-content-docs": "0.0.0-4999", + "@docusaurus/plugin-content-pages": "0.0.0-4999", + "clsx": "^1.1.1", + "parse-numeric-range": "^1.3.0", + "prism-react-renderer": "^1.3.1", + "tslib": "^2.4.0", + "utility-types": "^3.10.0" + } + }, + "@docusaurus/theme-search-algolia": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-0.0.0-4999.tgz", + "integrity": "sha512-JQ8pLgKMLCEG8tcbK2L2EC7xOuozchxQTlOc1YAJ4olSBbJt06r+8gBwwJ6TQHA1jzc3VYnVLtfU4HLDXA0ZZg==", + "requires": { + "@docsearch/react": "^3.0.0", + "@docusaurus/core": "0.0.0-4999", + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/plugin-content-docs": "0.0.0-4999", + "@docusaurus/theme-common": "0.0.0-4999", + "@docusaurus/theme-translations": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "@docusaurus/utils-validation": "0.0.0-4999", + "algoliasearch": "^4.13.0", + "algoliasearch-helper": "^3.8.2", + "clsx": "^1.1.1", + "eta": "^1.12.3", + "fs-extra": "^10.1.0", + "lodash": "^4.17.21", + "tslib": "^2.4.0", + "utility-types": "^3.10.0" + } + }, + "@docusaurus/theme-translations": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-0.0.0-4999.tgz", + "integrity": "sha512-gOwkq+UZGzdJHuNbMRQTt2qYWWyWBH6jIVNe4wlzGoNZo6fWza4qpgHJ5QV191/wBSi1gOQ0Nnzo90Pw2n0ySA==", + "requires": { + "fs-extra": "^10.1.0", + "tslib": "^2.4.0" + } + }, + "@docusaurus/types": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-0.0.0-4999.tgz", + "integrity": "sha512-hwoTiIVaHXTeqRlh1dOmCFAO0K193zMNt4WsVqiO6qHkNchdHvdJ9ssumNKF5PwmxD5qHRY9OwlC/LDoa9YPlw==", + "requires": { + "commander": "^5.1.0", + "history": "^4.9.0", + "joi": "^17.6.0", + "react-helmet-async": "^1.3.0", + "utility-types": "^3.10.0", + "webpack": "^5.72.1", + "webpack-merge": "^5.8.0" + } + }, + "@docusaurus/utils": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-0.0.0-4999.tgz", + "integrity": "sha512-xepIi3L2W7oYTeKpyLaFxih5dcjQqFJ3aTM8IkMmfqc3354k8NAEh08SZsII/5EsnCEMs9L7EfSJToIrrLI2Cw==", + "requires": { + "@docusaurus/logger": "0.0.0-4999", + "@svgr/webpack": "^6.2.1", + "file-loader": "^6.2.0", + "fs-extra": "^10.1.0", + "github-slugger": "^1.4.0", + "globby": "^11.1.0", + "gray-matter": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "micromatch": "^4.0.5", + "resolve-pathname": "^3.0.0", + "shelljs": "^0.8.5", + "tslib": "^2.4.0", + "url-loader": "^4.1.1", + "webpack": "^5.72.1" + } + }, + "@docusaurus/utils-common": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-0.0.0-4999.tgz", + "integrity": "sha512-QDQLKb9Jbt3GB2vZ6ymwD+3xDbqU3P2gxilaeUbWB1mw1CCtBziZKLDo4syCs2i3f8l+tqEft+e2KPkpuq05TQ==", + "requires": { + "tslib": "^2.4.0" + } + }, + "@docusaurus/utils-validation": { + "version": "0.0.0-4999", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-0.0.0-4999.tgz", + "integrity": "sha512-2JSqhk3RkJzK+4rsAusZlFtZKc1dAVPUWmSvs9AyPamnGdbEMiawj0vXJji/eIkzVgUMy+qYLT+THLa3DLUCsA==", + "requires": { + "@docusaurus/logger": "0.0.0-4999", + "@docusaurus/utils": "0.0.0-4999", + "joi": "^17.6.0", + "js-yaml": "^4.1.0", + "tslib": "^2.4.0" + } + }, + "@hapi/hoek": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", + "integrity": "sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==" + }, + "@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@jridgewell/gen-mapping": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", + "requires": { + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", + "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==" + }, + "@jridgewell/set-array": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.0.tgz", + "integrity": "sha512-SfJxIxNVYLTsKwzB3MoOQ1yxf4w/E6MdkvTgrgAt1bfxjSrLUoHMKrDOykwN14q65waezZIdqDneUIPh4/sKxg==" + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz", + "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==" + }, + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" + }, + "@mdx-js/mdx": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", + "integrity": "sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA==", + "requires": { + "@babel/core": "7.12.9", + "@babel/plugin-syntax-jsx": "7.12.1", + "@babel/plugin-syntax-object-rest-spread": "7.8.3", + "@mdx-js/util": "1.6.22", + "babel-plugin-apply-mdx-type-prop": "1.6.22", + "babel-plugin-extract-import-names": "1.6.22", + "camelcase-css": "2.0.1", + "detab": "2.0.4", + "hast-util-raw": "6.0.1", + "lodash.uniq": "4.5.0", + "mdast-util-to-hast": "10.0.1", + "remark-footnotes": "2.0.0", + "remark-mdx": "1.6.22", + "remark-parse": "8.0.3", + "remark-squeeze-paragraphs": "4.0.0", + "style-to-object": "0.3.0", + "unified": "9.2.0", + "unist-builder": "2.0.3", + "unist-util-visit": "2.0.3" + }, + "dependencies": { + "@babel/core": { + "version": "7.12.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", + "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.5", + "@babel/helper-module-transforms": "^7.12.1", + "@babel/helpers": "^7.12.5", + "@babel/parser": "^7.12.7", + "@babel/template": "^7.12.7", + "@babel/traverse": "^7.12.9", + "@babel/types": "^7.12.7", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz", + "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + } + } + }, + "@mdx-js/react": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz", + "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==", + "requires": {} + }, + "@mdx-js/util": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz", + "integrity": "sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==" + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==" + }, + "@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@sideway/formula": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", + "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==" + }, + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, + "@sindresorhus/is": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==" + }, + "@slorber/static-site-generator-webpack-plugin": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@slorber/static-site-generator-webpack-plugin/-/static-site-generator-webpack-plugin-4.0.4.tgz", + "integrity": "sha512-FvMavoWEIePps6/JwGCOLYKCRhuwIHhMtmbKpBFgzNkxwpa/569LfTkrbRk1m1I3n+ezJK4on9E1A6cjuZmD9g==", + "requires": { + "bluebird": "^3.7.1", + "cheerio": "^0.22.0", + "eval": "^0.1.8", + "webpack-sources": "^1.4.3" + }, + "dependencies": { + "cheerio": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz", + "integrity": "sha1-qbqoYKP5tZWmuBsahocxIe06Jp4=", + "requires": { + "css-select": "~1.2.0", + "dom-serializer": "~0.1.0", + "entities": "~1.1.1", + "htmlparser2": "^3.9.1", + "lodash.assignin": "^4.0.9", + "lodash.bind": "^4.1.4", + "lodash.defaults": "^4.0.1", + "lodash.filter": "^4.4.0", + "lodash.flatten": "^4.2.0", + "lodash.foreach": "^4.3.0", + "lodash.map": "^4.4.0", + "lodash.merge": "^4.4.0", + "lodash.pick": "^4.2.1", + "lodash.reduce": "^4.4.0", + "lodash.reject": "^4.4.0", + "lodash.some": "^4.4.0" + } + }, + "css-select": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", + "requires": { + "boolbase": "~1.0.0", + "css-what": "2.1", + "domutils": "1.5.1", + "nth-check": "~1.0.1" + } + }, + "css-what": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz", + "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==" + }, + "dom-serializer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", + "integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==", + "requires": { + "domelementtype": "^1.3.0", + "entities": "^1.1.1" + } + }, + "domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" + }, + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==" + }, + "htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "requires": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "requires": { + "boolbase": "~1.0.0" + } + } + } + }, + "@svgr/babel-plugin-add-jsx-attribute": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz", + "integrity": "sha512-MdPdhdWLtQsjd29Wa4pABdhWbaRMACdM1h31BY+c6FghTZqNGT7pEYdBoaGeKtdTOBC/XNFQaKVj+r/Ei2ryWA==", + "requires": {} + }, + "@svgr/babel-plugin-remove-jsx-attribute": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-6.0.0.tgz", + "integrity": "sha512-aVdtfx9jlaaxc3unA6l+M9YRnKIZjOhQPthLKqmTXC8UVkBLDRGwPKo+r8n3VZN8B34+yVajzPTZ+ptTSuZZCw==", + "requires": {} + }, + "@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-6.0.0.tgz", + "integrity": "sha512-Ccj42ApsePD451AZJJf1QzTD1B/BOU392URJTeXFxSK709i0KUsGtbwyiqsKu7vsYxpTM0IA5clAKDyf9RCZyA==", + "requires": {} + }, + "@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-6.0.0.tgz", + "integrity": "sha512-88V26WGyt1Sfd1emBYmBJRWMmgarrExpKNVmI9vVozha4kqs6FzQJ/Kp5+EYli1apgX44518/0+t9+NU36lThQ==", + "requires": {} + }, + "@svgr/babel-plugin-svg-dynamic-title": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-6.0.0.tgz", + "integrity": "sha512-F7YXNLfGze+xv0KMQxrl2vkNbI9kzT9oDK55/kUuymh1ACyXkMV+VZWX1zEhSTfEKh7VkHVZGmVtHg8eTZ6PRg==", + "requires": {} + }, + "@svgr/babel-plugin-svg-em-dimensions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-6.0.0.tgz", + "integrity": "sha512-+rghFXxdIqJNLQK08kwPBD3Z22/0b2tEZ9lKiL/yTfuyj1wW8HUXu4bo/XkogATIYuXSghVQOOCwURXzHGKyZA==", + "requires": {} + }, + "@svgr/babel-plugin-transform-react-native-svg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-6.0.0.tgz", + "integrity": "sha512-VaphyHZ+xIKv5v0K0HCzyfAaLhPGJXSk2HkpYfXIOKb7DjLBv0soHDxNv6X0vr2titsxE7klb++u7iOf7TSrFQ==", + "requires": {} + }, + "@svgr/babel-plugin-transform-svg-component": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-6.2.0.tgz", + "integrity": "sha512-bhYIpsORb++wpsp91fymbFkf09Z/YEKR0DnFjxvN+8JHeCUD2unnh18jIMKnDJTWtvpTaGYPXELVe4OOzFI0xg==", + "requires": {} + }, + "@svgr/babel-preset": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-6.2.0.tgz", + "integrity": "sha512-4WQNY0J71JIaL03DRn0vLiz87JXx0b9dYm2aA8XHlQJQoixMl4r/soYHm8dsaJZ3jWtkCiOYy48dp9izvXhDkQ==", + "requires": { + "@svgr/babel-plugin-add-jsx-attribute": "^6.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^6.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^6.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^6.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "^6.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "^6.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "^6.0.0", + "@svgr/babel-plugin-transform-svg-component": "^6.2.0" + } + }, + "@svgr/core": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-6.2.1.tgz", + "integrity": "sha512-NWufjGI2WUyrg46mKuySfviEJ6IxHUOm/8a3Ph38VCWSp+83HBraCQrpEM3F3dB6LBs5x8OElS8h3C0oOJaJAA==", + "requires": { + "@svgr/plugin-jsx": "^6.2.1", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.1" + } + }, + "@svgr/hast-util-to-babel-ast": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-6.2.1.tgz", + "integrity": "sha512-pt7MMkQFDlWJVy9ULJ1h+hZBDGFfSCwlBNW1HkLnVi7jUhyEXUaGYWi1x6bM2IXuAR9l265khBT4Av4lPmaNLQ==", + "requires": { + "@babel/types": "^7.15.6", + "entities": "^3.0.1" + } + }, + "@svgr/plugin-jsx": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-6.2.1.tgz", + "integrity": "sha512-u+MpjTsLaKo6r3pHeeSVsh9hmGRag2L7VzApWIaS8imNguqoUwDq/u6U/NDmYs/KAsrmtBjOEaAAPbwNGXXp1g==", + "requires": { + "@babel/core": "^7.15.5", + "@svgr/babel-preset": "^6.2.0", + "@svgr/hast-util-to-babel-ast": "^6.2.1", + "svg-parser": "^2.0.2" + } + }, + "@svgr/plugin-svgo": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-6.2.0.tgz", + "integrity": "sha512-oDdMQONKOJEbuKwuy4Np6VdV6qoaLLvoY86hjvQEgU82Vx1MSWRyYms6Sl0f+NtqxLI/rDVufATbP/ev996k3Q==", + "requires": { + "cosmiconfig": "^7.0.1", + "deepmerge": "^4.2.2", + "svgo": "^2.5.0" + } + }, + "@svgr/webpack": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-6.2.1.tgz", + "integrity": "sha512-h09ngMNd13hnePwgXa+Y5CgOjzlCvfWLHg+MBnydEedAnuLRzUHUJmGS3o2OsrhxTOOqEsPOFt5v/f6C5Qulcw==", + "requires": { + "@babel/core": "^7.15.5", + "@babel/plugin-transform-react-constant-elements": "^7.14.5", + "@babel/preset-env": "^7.15.6", + "@babel/preset-react": "^7.14.5", + "@babel/preset-typescript": "^7.15.0", + "@svgr/core": "^6.2.1", + "@svgr/plugin-jsx": "^6.2.1", + "@svgr/plugin-svgo": "^6.2.0" + } + }, + "@szmarczak/http-timer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", + "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "requires": { + "defer-to-connect": "^1.0.1" + } + }, + "@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==" + }, + "@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/bonjour": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", + "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "requires": { + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/connect-history-api-fallback": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz", + "integrity": "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==", + "requires": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "@types/eslint": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", + "integrity": "sha512-GE44+DNEyxxh2Kc6ro/VkIj+9ma0pO0bwv9+uHSyBrikYOHr8zYcdPvnBOp1aw8s+CjRvuSx7CyWqRrNFQ59mA==", + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==", + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "0.0.51", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", + "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" + }, + "@types/express": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", + "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/hast": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", + "integrity": "sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==", + "requires": { + "@types/unist": "*" + } + }, + "@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" + }, + "@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==" + }, + "@types/http-proxy": { + "version": "1.17.9", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz", + "integrity": "sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==", + "requires": { + "@types/node": "*" + } + }, + "@types/json-schema": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" + }, + "@types/mdast": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", + "integrity": "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==", + "requires": { + "@types/unist": "*" + } + }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + }, + "@types/node": { + "version": "17.0.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.30.tgz", + "integrity": "sha512-oNBIZjIqyHYP8VCNAV9uEytXVeXG2oR0w9lgAXro20eugRQfY002qr3CUl6BAe+Yf/z3CRjPdz27Pu6WWtuSRw==" + }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + }, + "@types/parse5": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz", + "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==" + }, + "@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" + }, + "@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" + }, + "@types/react": { + "version": "17.0.44", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz", + "integrity": "sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-router": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.18.tgz", + "integrity": "sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g==", + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "@types/react-router-config": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.6.tgz", + "integrity": "sha512-db1mx37a1EJDf1XeX8jJN7R3PZABmJQXR8r28yUjVMFSjkmnQo6X6pOEEmNl+Tp2gYQOGPdYbFIipBtdElZ3Yg==", + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, + "@types/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-pSAff4IAxJjfAXUG6tFkO7dsSbTmf8CtUpfhhZ5VhkRpC4628tJhh3+V6H1E+/Gs9piSzYKT5yzHO5M4GG9jkw==", + "requires": { + "@types/node": "*" + } + }, + "@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" + }, + "@types/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "requires": { + "@types/express": "*" + } + }, + "@types/serve-static": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", + "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/sockjs": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", + "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "requires": { + "@types/node": "*" + } + }, + "@types/unist": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", + "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==" + }, + "@types/ws": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", + "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "requires": { + "@types/node": "*" + } + }, + "@webassemblyjs/ast": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", + "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "requires": { + "@webassemblyjs/helper-numbers": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", + "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==" + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==" + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", + "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==" + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", + "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", + "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==" + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", + "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", + "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", + "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", + "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==" + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", + "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/helper-wasm-section": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-opt": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "@webassemblyjs/wast-printer": "1.11.1" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", + "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", + "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", + "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", + "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "dependencies": { + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + } + } + }, + "acorn": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", + "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==" + }, + "acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "requires": {} + }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" + }, + "address": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.0.tgz", + "integrity": "sha512-tNEZYz5G/zYunxFm7sfhAxkXEuLj3K6BKwv6ZURlsF6yiUQ65z0Q2wZW9L5cPUl9ocofGvXOdFYbFHp0+6MOig==" + }, + "aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + } + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "requires": {} + }, + "algoliasearch": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.13.0.tgz", + "integrity": "sha512-oHv4faI1Vl2s+YC0YquwkK/TsaJs79g2JFg5FDm2rKN12VItPTAeQ7hyJMHarOPPYuCnNC5kixbtcqvb21wchw==", + "requires": { + "@algolia/cache-browser-local-storage": "4.13.0", + "@algolia/cache-common": "4.13.0", + "@algolia/cache-in-memory": "4.13.0", + "@algolia/client-account": "4.13.0", + "@algolia/client-analytics": "4.13.0", + "@algolia/client-common": "4.13.0", + "@algolia/client-personalization": "4.13.0", + "@algolia/client-search": "4.13.0", + "@algolia/logger-common": "4.13.0", + "@algolia/logger-console": "4.13.0", + "@algolia/requester-browser-xhr": "4.13.0", + "@algolia/requester-common": "4.13.0", + "@algolia/requester-node-http": "4.13.0", + "@algolia/transporter": "4.13.0" + } + }, + "algoliasearch-helper": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.8.2.tgz", + "integrity": "sha512-AXxiF0zT9oYwl8ZBgU/eRXvfYhz7cBA5YrLPlw9inZHdaYF0QEya/f1Zp1mPYMXc1v6VkHwBq4pk6/vayBLICg==", + "requires": { + "@algolia/events": "^4.0.1" + } + }, + "ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "requires": { + "string-width": "^4.1.0" + }, + "dependencies": { + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + } + } + }, + "ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==" + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arg": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", + "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==" + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" + }, + "autoprefixer": { + "version": "10.4.7", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.7.tgz", + "integrity": "sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==", + "requires": { + "browserslist": "^4.20.3", + "caniuse-lite": "^1.0.30001335", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + } + }, + "axios": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", + "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "requires": { + "follow-redirects": "^1.14.7" + } + }, + "babel-loader": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", + "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", + "requires": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + } + }, + "babel-plugin-apply-mdx-type-prop": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz", + "integrity": "sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==", + "requires": { + "@babel/helper-plugin-utils": "7.10.4", + "@mdx-js/util": "1.6.22" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz", + "integrity": "sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==", + "requires": { + "object.assign": "^4.1.0" + } + }, + "babel-plugin-extract-import-names": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz", + "integrity": "sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ==", + "requires": { + "@babel/helper-plugin-utils": "7.10.4" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, + "babel-plugin-polyfill-corejs2": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.1.tgz", + "integrity": "sha512-v7/T6EQcNfVLfcN2X8Lulb7DjprieyLWJK/zOWH5DUYcAgex9sP3h25Q+DLsX9TloXe3y1O8l2q2Jv9q8UVB9w==", + "requires": { + "@babel/compat-data": "^7.13.11", + "@babel/helper-define-polyfill-provider": "^0.3.1", + "semver": "^6.1.1" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz", + "integrity": "sha512-G3uJih0XWiID451fpeFaYGVuxHEjzKTHtc9uGFEjR6hHrvNzeS/PX+LLLcetJcytsB5m4j+K3o/EpXJNb/5IEQ==", + "requires": { + "@babel/helper-define-polyfill-provider": "^0.3.1", + "core-js-compat": "^3.21.0" + } + }, + "babel-plugin-polyfill-regenerator": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.1.tgz", + "integrity": "sha512-Y2B06tvgHYt1x0yz17jGkGeeMr5FeKUu+ASJ+N6nB5lQ8Dapfg42i0OVrf8PNGJ3zKL4A23snMi1IRwrqqND7A==", + "requires": { + "@babel/helper-define-polyfill-provider": "^0.3.1" + } + }, + "bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "base16": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", + "integrity": "sha1-4pf2DX7BAUp6lxo568ipjAtoHnA=" + }, + "batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=" + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "body-parser": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", + "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==", + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.10.3", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "dependencies": { + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "bonjour-service": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.12.tgz", + "integrity": "sha512-pMmguXYCu63Ug37DluMKEHdxc+aaIf/ay4YbF8Gxtba+9d3u+rmEWy61VK3Z3hp8Rskok3BunHYnG0dUHAsblw==", + "requires": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.4" + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" + }, + "boxen": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-6.2.1.tgz", + "integrity": "sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==", + "requires": { + "ansi-align": "^3.0.1", + "camelcase": "^6.2.0", + "chalk": "^4.1.2", + "cli-boxes": "^3.0.0", + "string-width": "^5.0.1", + "type-fest": "^2.5.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "requires": { + "fill-range": "^7.0.1" + } + }, + "browserslist": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", + "integrity": "sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==", + "requires": { + "caniuse-lite": "^1.0.30001332", + "electron-to-chromium": "^1.4.118", + "escalade": "^3.1.1", + "node-releases": "^2.0.3", + "picocolors": "^1.0.0" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "cacheable-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", + "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^3.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^1.0.2" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + }, + "normalize-url": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==" + } + } + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" + }, + "camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "requires": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + }, + "camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" + }, + "caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "requires": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "caniuse-lite": { + "version": "1.0.30001339", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001339.tgz", + "integrity": "sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==" + }, + "ccount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", + "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==" + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==" + }, + "character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==" + }, + "character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==" + }, + "cheerio": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz", + "integrity": "sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw==", + "requires": { + "cheerio-select": "^1.5.0", + "dom-serializer": "^1.3.2", + "domhandler": "^4.2.0", + "htmlparser2": "^6.1.0", + "parse5": "^6.0.1", + "parse5-htmlparser2-tree-adapter": "^6.0.1", + "tslib": "^2.2.0" + } + }, + "cheerio-select": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-1.6.0.tgz", + "integrity": "sha512-eq0GdBvxVFbqWgmCm7M3XGs1I8oLy/nExUnh6oLqmBditPO9AqQJrkslDpMun/hZ0yyTs8L0m85OHp4ho6Qm9g==", + "requires": { + "css-select": "^4.3.0", + "css-what": "^6.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.3.1", + "domutils": "^2.8.0" + } + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==" + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + }, + "clean-css": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz", + "integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==", + "requires": { + "source-map": "~0.6.0" + } + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==" + }, + "cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==" + }, + "cli-table3": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.2.tgz", + "integrity": "sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw==", + "requires": { + "@colors/colors": "1.5.0", + "string-width": "^4.2.0" + }, + "dependencies": { + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + } + } + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "requires": { + "mimic-response": "^1.0.0" + } + }, + "clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" + }, + "collapse-white-space": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz", + "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==" + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "colord": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz", + "integrity": "sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==" + }, + "colorette": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", + "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==" + }, + "combine-promises": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/combine-promises/-/combine-promises-1.1.0.tgz", + "integrity": "sha512-ZI9jvcLDxqwaXEixOhArm3r7ReIivsXkpbyEWyeOhzz1QS0iSgBPnWvEqvIQtYyamGCYA88gFhmUrs9hrrQ0pg==" + }, + "comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==" + }, + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + }, + "dependencies": { + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + } + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "requires": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + } + }, + "connect-history-api-fallback": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz", + "integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==" + }, + "consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==" + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "copy-text-to-clipboard": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz", + "integrity": "sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q==" + }, + "copy-webpack-plugin": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", + "integrity": "sha512-xFVltahqlsRcyyJqQbDY6EYTtyQZF9rf+JPjwHObLdPFMEISqkFkr7mFoVOC6BfYS/dNThyoQKvziugm+OnwBg==", + "requires": { + "fast-glob": "^3.2.7", + "glob-parent": "^6.0.1", + "globby": "^12.0.2", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==" + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "requires": { + "is-glob": "^4.0.3" + } + }, + "globby": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz", + "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==", + "requires": { + "array-union": "^3.0.1", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.7", + "ignore": "^5.1.9", + "merge2": "^1.4.1", + "slash": "^4.0.0" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + }, + "slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==" + } + } + }, + "core-js": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.5.tgz", + "integrity": "sha512-VP/xYuvJ0MJWRAobcmQ8F2H6Bsn+s7zqAAjFaHGBMc5AQm7zaelhD1LGduFn2EehEcQcU+br6t+fwbpQ5d1ZWA==" + }, + "core-js-compat": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.22.3.tgz", + "integrity": "sha512-wliMbvPI2idgFWpFe7UEyHMvu6HWgW8WA+HnDRtgzoSDYvXFMpoGX1H3tPDDXrcfUSyXafCLDd7hOeMQHEZxGw==", + "requires": { + "browserslist": "^4.20.3", + "semver": "7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==" + } + } + }, + "core-js-pure": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.3.tgz", + "integrity": "sha512-oN88zz7nmKROMy8GOjs+LN+0LedIvbMdnB5XsTlhcOg1WGARt9l0LFg0zohdoFmCsEZ1h2ZbSQ6azj3M+vhzwQ==" + }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "cosmiconfig": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "requires": { + "node-fetch": "2.6.7" + } + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" + }, + "css-declaration-sorter": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.2.2.tgz", + "integrity": "sha512-Ufadglr88ZLsrvS11gjeu/40Lw74D9Am/Jpr3LlYm5Q4ZP5KdlUhG+6u2EjyXeZcxmZ2h1ebCKngDjolpeLHpg==", + "requires": {} + }, + "css-loader": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz", + "integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==", + "requires": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.7", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.5" + } + }, + "css-minimizer-webpack-plugin": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", + "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "requires": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + } + } + }, + "css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + } + }, + "css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "requires": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" + }, + "cssnano": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.7.tgz", + "integrity": "sha512-pVsUV6LcTXif7lvKKW9ZrmX+rGRzxkEdJuVJcp5ftUjWITgwam5LMZOgaTvUrWPkcORBey6he7JKb4XAJvrpKg==", + "requires": { + "cssnano-preset-default": "^5.2.7", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + } + }, + "cssnano-preset-advanced": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.3.tgz", + "integrity": "sha512-AB9SmTSC2Gd8T7PpKUsXFJ3eNsg7dc4CTZ0+XAJ29MNxyJsrCEk7N1lw31bpHrsQH2PVJr21bbWgGAfA9j0dIA==", + "requires": { + "autoprefixer": "^10.3.7", + "cssnano-preset-default": "^5.2.7", + "postcss-discard-unused": "^5.1.0", + "postcss-merge-idents": "^5.1.1", + "postcss-reduce-idents": "^5.2.0", + "postcss-zindex": "^5.1.0" + } + }, + "cssnano-preset-default": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.7.tgz", + "integrity": "sha512-JiKP38ymZQK+zVKevphPzNSGHSlTI+AOwlasoSRtSVMUU285O7/6uZyd5NbW92ZHp41m0sSHe6JoZosakj63uA==", + "requires": { + "css-declaration-sorter": "^6.2.2", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.0", + "postcss-convert-values": "^5.1.0", + "postcss-discard-comments": "^5.1.1", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.4", + "postcss-merge-rules": "^5.1.1", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.2", + "postcss-minify-selectors": "^5.2.0", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.0", + "postcss-normalize-repeat-style": "^5.1.0", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.0", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.1", + "postcss-reduce-initial": "^5.1.0", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + } + }, + "cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "requires": {} + }, + "csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "requires": { + "css-tree": "^1.1.2" + } + }, + "csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "requires": { + "mimic-response": "^1.0.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" + }, + "default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "requires": { + "execa": "^5.0.0" + } + }, + "defer-to-connect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + }, + "define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==" + }, + "define-properties": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", + "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "requires": { + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "del": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/del/-/del-6.0.0.tgz", + "integrity": "sha512-1shh9DQ23L16oXSZKB2JxpL7iMy2E0S9d517ptA1P8iw0alkPtQcrKH7ru31rYtKwF499HkTu+DRzq3TCKDFRQ==", + "requires": { + "globby": "^11.0.1", + "graceful-fs": "^4.2.4", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.2", + "p-map": "^4.0.0", + "rimraf": "^3.0.2", + "slash": "^3.0.0" + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, + "detab": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detab/-/detab-2.0.4.tgz", + "integrity": "sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==", + "requires": { + "repeat-string": "^1.5.4" + } + }, + "detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, + "detect-port": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.3.0.tgz", + "integrity": "sha512-E+B1gzkl2gqxt1IhUzwjrxBKRqx1UzC3WLONHinn8S3T6lwV/agVCyitiFOsGJ/eYuEUBvD71MZHy3Pv1G9doQ==", + "requires": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "detect-port-alt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", + "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "requires": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "requires": { + "path-type": "^4.0.0" + } + }, + "dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha1-s55/HabrCnW6nBcySzR1PEfgZU0=" + }, + "dns-packet": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.3.1.tgz", + "integrity": "sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw==", + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, + "dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "requires": { + "utila": "~0.4" + } + }, + "dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "dependencies": { + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" + } + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "requires": { + "domelementtype": "^2.2.0" + } + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + } + }, + "dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "requires": { + "is-obj": "^2.0.0" + }, + "dependencies": { + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + } + } + }, + "duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + }, + "duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + }, + "eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "electron-to-chromium": { + "version": "1.4.127", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.127.tgz", + "integrity": "sha512-nhD6S8nKI0O2MueC6blNOEZio+/PWppE/pevnf3LOlQA/fKPCrDp2Ao4wx4LFwmIkJpVdFdn2763YWLy9ENIZg==" + }, + "emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" + }, + "emoticon": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-3.2.0.tgz", + "integrity": "sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg==" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "requires": { + "once": "^1.4.0" + } + }, + "enhanced-resolve": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz", + "integrity": "sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==", + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==" + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-module-lexer": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" + }, + "escape-goat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + "eta": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/eta/-/eta-1.12.3.tgz", + "integrity": "sha512-qHixwbDLtekO/d51Yr4glcaUJCIjGVJyTzuqV4GPlgZo1YpgOKG+avQynErZIYrfM6JIJdtiG2Kox8tbb+DoGg==" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "eval": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.8.tgz", + "integrity": "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==", + "requires": { + "@types/node": "*", + "require-like": ">= 0.1.1" + } + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "dependencies": { + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" + } + } + }, + "express": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz", + "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==", + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.0", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.10.3", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "fast-url-parser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", + "integrity": "sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0=", + "requires": { + "punycode": "^1.3.2" + } + }, + "fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "requires": { + "reusify": "^1.0.4" + } + }, + "faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "fbemitter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz", + "integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==", + "requires": { + "fbjs": "^3.0.0" + } + }, + "fbjs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.4.tgz", + "integrity": "sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ==", + "requires": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.30" + } + }, + "fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==" + }, + "feed": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", + "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", + "requires": { + "xml-js": "^1.6.11" + } + }, + "file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==" + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "flux": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.3.tgz", + "integrity": "sha512-yKAbrp7JhZhj6uiT1FTuVMlIAT1J4jqEyBpFApi1kxpGZCvacMVc/t1pMQyotqHhAgvoE3bNvAykhCo2CLjnYw==", + "requires": { + "fbemitter": "^3.0.0", + "fbjs": "^3.0.1" + } + }, + "follow-redirects": { + "version": "1.14.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", + "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" + }, + "fork-ts-checker-webpack-plugin": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", + "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + } + }, + "fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==" + } + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "requires": { + "pump": "^3.0.0" + } + }, + "github-slugger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.4.0.tgz", + "integrity": "sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==" + }, + "glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "requires": { + "is-glob": "^4.0.1" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + }, + "global-dirs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz", + "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==", + "requires": { + "ini": "2.0.0" + }, + "dependencies": { + "ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==" + } + } + }, + "global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "requires": { + "global-prefix": "^3.0.0" + } + }, + "global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "requires": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "dependencies": { + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "got": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", + "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "requires": { + "@sindresorhus/is": "^0.14.0", + "@szmarczak/http-timer": "^1.1.2", + "cacheable-request": "^6.0.0", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^4.1.0", + "lowercase-keys": "^1.0.1", + "mimic-response": "^1.0.1", + "p-cancelable": "^1.0.0", + "to-readable-stream": "^1.0.0", + "url-parse-lax": "^3.0.0" + } + }, + "graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + }, + "gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "requires": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } + } + }, + "gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "requires": { + "duplexer": "^0.1.2" + } + }, + "handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "has-property-descriptors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "requires": { + "get-intrinsic": "^1.1.1" + } + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, + "has-yarn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==" + }, + "hast-to-hyperscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", + "integrity": "sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==", + "requires": { + "@types/unist": "^2.0.3", + "comma-separated-tokens": "^1.0.0", + "property-information": "^5.3.0", + "space-separated-tokens": "^1.0.0", + "style-to-object": "^0.3.0", + "unist-util-is": "^4.0.0", + "web-namespaces": "^1.0.0" + } + }, + "hast-util-from-parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-6.0.1.tgz", + "integrity": "sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==", + "requires": { + "@types/parse5": "^5.0.0", + "hastscript": "^6.0.0", + "property-information": "^5.0.0", + "vfile": "^4.0.0", + "vfile-location": "^3.2.0", + "web-namespaces": "^1.0.0" + } + }, + "hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==" + }, + "hast-util-raw": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-6.0.1.tgz", + "integrity": "sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig==", + "requires": { + "@types/hast": "^2.0.0", + "hast-util-from-parse5": "^6.0.0", + "hast-util-to-parse5": "^6.0.0", + "html-void-elements": "^1.0.0", + "parse5": "^6.0.0", + "unist-util-position": "^3.0.0", + "vfile": "^4.0.0", + "web-namespaces": "^1.0.0", + "xtend": "^4.0.0", + "zwitch": "^1.0.0" + } + }, + "hast-util-to-parse5": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz", + "integrity": "sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==", + "requires": { + "hast-to-hyperscript": "^9.0.0", + "property-information": "^5.0.0", + "web-namespaces": "^1.0.0", + "xtend": "^4.0.0", + "zwitch": "^1.0.0" + } + }, + "hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "requires": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" + }, + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, + "hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=", + "requires": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" + }, + "html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "requires": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "dependencies": { + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==" + } + } + }, + "html-tags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz", + "integrity": "sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==" + }, + "html-void-elements": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz", + "integrity": "sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==" + }, + "html-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", + "requires": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + } + }, + "htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + }, + "dependencies": { + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" + } + } + }, + "http-cache-semantics": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + }, + "http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=" + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "http-parser-js": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.6.tgz", + "integrity": "sha512-vDlkRPDJn93swjcjqMSaGSPABbIarsr1TLAui/gLDXzV5VsJNdXNzMYDyNBLQkjWQCJ1uizu8T2oDMhmGt0PRA==" + }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "requires": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "dependencies": { + "is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==" + } + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "requires": {} + }, + "ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==" + }, + "image-size": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.1.tgz", + "integrity": "sha512-VAwkvNSNGClRw9mDHhc5Efax8PLlsOGcUTh0T/LIriC8vPA3U5PdqXWqkz406MoYHMKW8Uf9gWr05T/rYB44kQ==", + "requires": { + "queue": "6.0.2" + } + }, + "immer": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", + "integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==" + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=" + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" + }, + "infima": { + "version": "0.2.0-alpha.39", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.39.tgz", + "integrity": "sha512-UyYiwD3nwHakGhuOUfpe3baJ8gkiPpRVx4a4sE/Ag+932+Y6swtLsdPoRR8ezhwqGnduzxmFkjumV9roz6QoLw==" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" + }, + "interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==" + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "ipaddr.js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", + "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==" + }, + "is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==" + }, + "is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "requires": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==" + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "requires": { + "has": "^1.0.3" + } + }, + "is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==" + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==" + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==" + }, + "is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "requires": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + } + }, + "is-npm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-5.0.0.tgz", + "integrity": "sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==" + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=" + }, + "is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==" + }, + "is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==" + }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==" + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "requires": { + "isobject": "^3.0.1" + } + }, + "is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=" + }, + "is-root": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==" + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-whitespace-character": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", + "integrity": "sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==" + }, + "is-word-character": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz", + "integrity": "sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==" + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "requires": { + "is-docker": "^2.0.0" + } + }, + "is-yarn-global": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==" + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "joi": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.6.0.tgz", + "integrity": "sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==", + "requires": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.0", + "@sideway/pinpoint": "^2.0.0" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "requires": { + "argparse": "^2.0.1" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" + }, + "json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==" + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "keyv": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", + "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "requires": { + "json-buffer": "3.0.0" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" + }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" + }, + "klona": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", + "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==" + }, + "latest-version": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", + "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "requires": { + "package-json": "^6.3.0" + } + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" + }, + "lilconfig": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz", + "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==" + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==" + }, + "loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lodash.assignin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz", + "integrity": "sha1-uo31+4QesKPoBEIysOJjqNxqKKI=" + }, + "lodash.bind": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz", + "integrity": "sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU=" + }, + "lodash.curry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz", + "integrity": "sha1-JI42By7ekGUB11lmIAqG2riyMXA=" + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" + }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + }, + "lodash.filter": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz", + "integrity": "sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4=" + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, + "lodash.flow": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", + "integrity": "sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o=" + }, + "lodash.foreach": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", + "integrity": "sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM=" + }, + "lodash.map": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", + "integrity": "sha1-dx7Hg540c9nEzeKLGTlMNWL09tM=" + }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=" + }, + "lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs=" + }, + "lodash.reject": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reject/-/lodash.reject-4.6.0.tgz", + "integrity": "sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU=" + }, + "lodash.some": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz", + "integrity": "sha1-G7nzFO9ri63tE7VJFpsqlF62jk0=" + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "requires": { + "tslib": "^2.0.3" + } + }, + "lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "markdown-escapes": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz", + "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==" + }, + "mdast-squeeze-paragraphs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz", + "integrity": "sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ==", + "requires": { + "unist-util-remove": "^2.0.0" + } + }, + "mdast-util-definitions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz", + "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==", + "requires": { + "unist-util-visit": "^2.0.0" + } + }, + "mdast-util-to-hast": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz", + "integrity": "sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA==", + "requires": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "mdast-util-definitions": "^4.0.0", + "mdurl": "^1.0.0", + "unist-builder": "^2.0.0", + "unist-util-generated": "^1.0.0", + "unist-util-position": "^3.0.0", + "unist-util-visit": "^2.0.0" + } + }, + "mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==" + }, + "mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "memfs": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.1.tgz", + "integrity": "sha512-1c9VPVvW5P7I85c35zAdEr1TD5+F11IToIHIlrVIcflfnzPkJa0ZoYEoEdYDP8KgPFoSZ/opDrUsAoZWym3mtw==", + "requires": { + "fs-monkey": "1.0.3" + } + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==" + }, + "mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "requires": { + "mime-db": "~1.33.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" + }, + "mini-create-react-context": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", + "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", + "requires": { + "@babel/runtime": "^7.12.1", + "tiny-warning": "^1.0.3" + } + }, + "mini-css-extract-plugin": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.0.tgz", + "integrity": "sha512-ndG8nxCEnAemsg4FSgS+yNyHKgkTB4nPKqCOgh65j3/30qqC5RaSQQXMm++Y6sb6E1zRSxPkztj9fqxhS1Eo6w==", + "requires": { + "schema-utils": "^4.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + } + } + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" + }, + "mrmime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.0.tgz", + "integrity": "sha512-a70zx7zFfVO7XpnQ2IX1Myh9yY4UYvfld/dikWRnsXxbyvMcfz+u6UfgNAtH+k2QqtJuzVpv6eLTx1G2+WKZbQ==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "multicast-dns": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.4.tgz", + "integrity": "sha512-XkCYOU+rr2Ft3LI6w4ye51M3VK31qJXFIxu0XLw169PtKG0Zx47OrXeVW/GCYOfpC9s1yyyf1S+L8/4LY0J9Zw==", + "requires": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + } + }, + "nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==" + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "requires": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "requires": { + "lodash": "^4.17.21" + } + }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==" + }, + "node-releases": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.4.tgz", + "integrity": "sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==" + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=" + }, + "normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "requires": { + "path-key": "^3.0.0" + } + }, + "nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha1-y480xTIT2JVyP8urkH6UIq28r7E=" + }, + "nth-check": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", + "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", + "requires": { + "boolbase": "^1.0.0" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-inspect": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", + "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "open": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", + "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", + "requires": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + } + }, + "opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==" + }, + "p-cancelable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "requires": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, + "package-json": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", + "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "requires": { + "got": "^9.6.0", + "registry-auth-token": "^4.0.0", + "registry-url": "^5.0.0", + "semver": "^6.2.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "requires": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "requires": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + } + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "parse-numeric-range": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", + "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" + }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, + "parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "requires": { + "parse5": "^6.0.1" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=" + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + } + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "requires": { + "find-up": "^4.0.0" + } + }, + "pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "requires": { + "find-up": "^3.0.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + } + } + }, + "postcss": { + "version": "8.4.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.13.tgz", + "integrity": "sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==", + "requires": { + "nanoid": "^3.3.3", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "requires": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-colormin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", + "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-convert-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.0.tgz", + "integrity": "sha512-GkyPbZEYJiWtQB0KZ0X6qusqFHUepguBCNFi9t5JJc7I2OTXG7C0twbTLvCfaKOLl3rSXmpAwV7W5txd91V84g==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-discard-comments": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.1.tgz", + "integrity": "sha512-5JscyFmvkUxz/5/+TB3QTTT9Gi9jHkcn8dcmmuN68JQcv3aQg4y88yEHHhwFB52l/NkaJ43O0dbksGMAo49nfQ==", + "requires": {} + }, + "postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "requires": {} + }, + "postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "requires": {} + }, + "postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "requires": {} + }, + "postcss-discard-unused": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-5.1.0.tgz", + "integrity": "sha512-KwLWymI9hbwXmJa0dkrzpRbSJEh0vVUd7r8t0yOGPcfKzyJJxFM8kLyC5Ev9avji6nY95pOp1W6HqIrfT+0VGw==", + "requires": { + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-loader": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", + "requires": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + } + }, + "postcss-merge-idents": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-5.1.1.tgz", + "integrity": "sha512-pCijL1TREiCoog5nQp7wUe+TUonA2tC2sQ54UGeMmryK3UFGIYKqDyjnqd6RcuI4znFn9hWSLNN8xKE/vWcUQw==", + "requires": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-merge-longhand": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.4.tgz", + "integrity": "sha512-hbqRRqYfmXoGpzYKeW0/NCZhvNyQIlQeWVSao5iKWdyx7skLvCfQFGIUsP9NUs3dSbPac2IC4Go85/zG+7MlmA==", + "requires": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.0" + } + }, + "postcss-merge-rules": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.1.tgz", + "integrity": "sha512-8wv8q2cXjEuCcgpIB1Xx1pIy8/rhMPIQqYKNzEdyx37m6gpq83mQQdCxgIkFgliyEnKvdwJf/C61vN4tQDq4Ww==", + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "requires": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-params": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.2.tgz", + "integrity": "sha512-aEP+p71S/urY48HWaRHasyx4WHQJyOYaKpQ6eXl8k0kxg66Wt/30VR6/woh8THgcpRbonJD5IeD+CzNhPi1L8g==", + "requires": { + "browserslist": "^4.16.6", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-selectors": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.0.tgz", + "integrity": "sha512-vYxvHkW+iULstA+ctVNx0VoRAR4THQQRkG77o0oa4/mBS0OzGvvzLIvHDv/nNEM0crzN2WIyFU5X7wZhaUK3RA==", + "requires": { + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "requires": {} + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "requires": { + "icss-utils": "^5.0.0" + } + }, + "postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "requires": {} + }, + "postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-positions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.0.tgz", + "integrity": "sha512-8gmItgA4H5xiUxgN/3TVvXRoJxkAWLW6f/KKhdsH03atg0cB8ilXnrB5PpSshwVu/dD2ZsRFQcR1OEmSBDAgcQ==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-repeat-style": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.0.tgz", + "integrity": "sha512-IR3uBjc+7mcWGL6CtniKNQ4Rr5fTxwkaDHwMBDGGs1x9IVRkYIT/M4NelZWkAOBdV6v3Z9S46zqaKGlyzHSchw==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-unicode": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz", + "integrity": "sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==", + "requires": { + "browserslist": "^4.16.6", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "requires": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-ordered-values": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.1.tgz", + "integrity": "sha512-7lxgXF0NaoMIgyihL/2boNAEZKiW0+HkMhdKMTD93CjW8TdCy2hSdj8lsAo+uwm7EDG16Da2Jdmtqpedl0cMfw==", + "requires": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-reduce-idents": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-5.2.0.tgz", + "integrity": "sha512-BTrLjICoSB6gxbc58D5mdBK8OhXRDqud/zodYfdSi52qvDHdMwk+9kB9xsM8yJThH/sZU5A6QVSmMmaN001gIg==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-reduce-initial": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz", + "integrity": "sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==", + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0" + } + }, + "postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-sort-media-queries": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-4.2.1.tgz", + "integrity": "sha512-9VYekQalFZ3sdgcTjXMa0dDjsfBVHXlraYJEMiOJ/2iMmI2JGCMavP16z3kWOaRu8NSaJCTgVpB/IVpH5yT9YQ==", + "requires": { + "sort-css-media-queries": "2.0.4" + } + }, + "postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "requires": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + } + }, + "postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "requires": { + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "postcss-zindex": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-5.1.0.tgz", + "integrity": "sha512-fgFMf0OtVSBR1va1JNHYgMxYk73yhn/qb4uQDq1DLGYolz8gHCyr/sesEuGUaYs58E3ZJRcpoGuPVoB7Meiq9A==", + "requires": {} + }, + "prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=" + }, + "pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "requires": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "pretty-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", + "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==" + }, + "prism-react-renderer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-1.3.1.tgz", + "integrity": "sha512-xUeDMEz074d0zc5y6rxiMp/dlC7C+5IDDlaEUlcBOFE2wddz7hz5PNupb087mPwTt7T9BrFmewObfCBuf/LKwQ==", + "requires": {} + }, + "prismjs": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.28.0.tgz", + "integrity": "sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw==" + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, + "prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "requires": { + "xtend": "^4.0.0" + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "dependencies": { + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + } + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "pupa": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", + "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", + "requires": { + "escape-goat": "^2.0.0" + } + }, + "pure-color": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz", + "integrity": "sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4=" + }, + "qs": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", + "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "requires": { + "side-channel": "^1.0.4" + } + }, + "queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "requires": { + "inherits": "~2.0.3" + } + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, + "raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "dependencies": { + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + } + } + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + } + } + }, + "react": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "react-base16-styling": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz", + "integrity": "sha1-7yFW1mz0E5aVyKFniGy2nqZgeSw=", + "requires": { + "base16": "^1.0.0", + "lodash.curry": "^4.0.1", + "lodash.flow": "^3.3.0", + "pure-color": "^1.2.0" + } + }, + "react-dev-utils": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "requires": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "loader-utils": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.0.tgz", + "integrity": "sha512-HVl9ZqccQihZ7JM85dco1MvO9G+ONvxoGa9rkhzFsneGLKSUg1gJf9bWzhRhcvm2qChhWpebQhP44qxjKIUCaQ==" + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "requires": { + "p-locate": "^5.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "requires": { + "p-limit": "^3.0.2" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + } + }, + "react-error-overlay": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", + "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" + }, + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, + "react-helmet-async": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", + "integrity": "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==", + "requires": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "react-json-view": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/react-json-view/-/react-json-view-1.21.3.tgz", + "integrity": "sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==", + "requires": { + "flux": "^4.0.1", + "react-base16-styling": "^0.6.0", + "react-lifecycles-compat": "^3.0.4", + "react-textarea-autosize": "^8.3.2" + } + }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "react-loadable": { + "version": "npm:@docusaurus/react-loadable@5.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz", + "integrity": "sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==", + "requires": { + "@types/react": "*", + "prop-types": "^15.6.2" + } + }, + "react-loadable-ssr-addon-v5-slorber": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz", + "integrity": "sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==", + "requires": { + "@babel/runtime": "^7.10.3" + } + }, + "react-router": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.1.tgz", + "integrity": "sha512-v+zwjqb7bakqgF+wMVKlAPTca/cEmPOvQ9zt7gpSNyPXau1+0qvuYZ5BWzzNDP1y6s15zDwgb9rPN63+SIniRQ==", + "requires": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, + "react-router-config": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-router-config/-/react-router-config-5.1.1.tgz", + "integrity": "sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, + "react-router-dom": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.1.tgz", + "integrity": "sha512-f0pj/gMAbv9e8gahTmCEY20oFhxhrmHwYeIwH5EO5xu0qme+wXtsdB8YfUOAZzUz4VaXmb58m3ceiLtjMhqYmQ==", + "requires": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.1", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, + "react-textarea-autosize": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz", + "integrity": "sha512-2XlHXK2TDxS6vbQaoPbMOfQ8GK7+irc2fVK6QFIcC8GOnH3zI/v481n+j1L0WaPVvKxwesnY93fEfH++sus2rQ==", + "requires": { + "@babel/runtime": "^7.10.2", + "use-composed-ref": "^1.0.0", + "use-latest": "^1.0.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "requires": { + "picomatch": "^2.2.1" + } + }, + "reading-time": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", + "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==" + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "requires": { + "resolve": "^1.1.6" + } + }, + "recursive-readdir": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz", + "integrity": "sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg==", + "requires": { + "minimatch": "3.0.4" + }, + "dependencies": { + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" + }, + "regenerate-unicode-properties": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz", + "integrity": "sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==", + "requires": { + "regenerate": "^1.4.2" + } + }, + "regenerator-runtime": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" + }, + "regenerator-transform": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz", + "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==", + "requires": { + "@babel/runtime": "^7.8.4" + } + }, + "regexpu-core": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.0.1.tgz", + "integrity": "sha512-CriEZlrKK9VJw/xQGJpQM5rY88BtuL8DM+AEwvcThHilbxiTAy8vq4iJnd2tqq8wLmjbGZzP7ZcKFjbGkmEFrw==", + "requires": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.0.1", + "regjsgen": "^0.6.0", + "regjsparser": "^0.8.2", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.0.0" + } + }, + "registry-auth-token": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz", + "integrity": "sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==", + "requires": { + "rc": "^1.2.8" + } + }, + "registry-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", + "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "requires": { + "rc": "^1.2.8" + } + }, + "regjsgen": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.6.0.tgz", + "integrity": "sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA==" + }, + "regjsparser": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.8.4.tgz", + "integrity": "sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA==", + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=" + } + } + }, + "rehype-parse": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-6.0.2.tgz", + "integrity": "sha512-0S3CpvpTAgGmnz8kiCyFLGuW5yA4OQhyNTm/nwPopZ7+PI11WnGl1TTWTGv/2hPEe/g2jRLlhVVSsoDH8waRug==", + "requires": { + "hast-util-from-parse5": "^5.0.0", + "parse5": "^5.0.0", + "xtend": "^4.0.0" + }, + "dependencies": { + "hast-util-from-parse5": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-5.0.3.tgz", + "integrity": "sha512-gOc8UB99F6eWVWFtM9jUikjN7QkWxB3nY0df5Z0Zq1/Nkwl5V4hAAsl0tmwlgWl/1shlTF8DnNYLO8X6wRV9pA==", + "requires": { + "ccount": "^1.0.3", + "hastscript": "^5.0.0", + "property-information": "^5.0.0", + "web-namespaces": "^1.1.2", + "xtend": "^4.0.1" + } + }, + "hastscript": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-5.1.2.tgz", + "integrity": "sha512-WlztFuK+Lrvi3EggsqOkQ52rKbxkXL3RwB6t5lwoa8QLMemoWfBuL43eDrwOamJyR7uKQKdmKYaBH1NZBiIRrQ==", + "requires": { + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + } + }, + "parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" + } + } + }, + "relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=" + }, + "remark-admonitions": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/remark-admonitions/-/remark-admonitions-1.2.1.tgz", + "integrity": "sha512-Ji6p68VDvD+H1oS95Fdx9Ar5WA2wcDA4kwrrhVU7fGctC6+d3uiMICu7w7/2Xld+lnU7/gi+432+rRbup5S8ow==", + "requires": { + "rehype-parse": "^6.0.2", + "unified": "^8.4.2", + "unist-util-visit": "^2.0.1" + }, + "dependencies": { + "unified": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-8.4.2.tgz", + "integrity": "sha512-JCrmN13jI4+h9UAyKEoGcDZV+i1E7BLFuG7OsaDvTXI5P0qhHX+vZO/kOhz9jn8HGENDKbwSeB0nVOg4gVStGA==", + "requires": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + } + } + } + }, + "remark-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-2.2.0.tgz", + "integrity": "sha512-P3cj9s5ggsUvWw5fS2uzCHJMGuXYRb0NnZqYlNecewXt8QBU9n5vW3DUUKOhepS8F9CwdMx9B8a3i7pqFWAI5w==", + "requires": { + "emoticon": "^3.2.0", + "node-emoji": "^1.10.0", + "unist-util-visit": "^2.0.3" + } + }, + "remark-footnotes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/remark-footnotes/-/remark-footnotes-2.0.0.tgz", + "integrity": "sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ==" + }, + "remark-mdx": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-1.6.22.tgz", + "integrity": "sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ==", + "requires": { + "@babel/core": "7.12.9", + "@babel/helper-plugin-utils": "7.10.4", + "@babel/plugin-proposal-object-rest-spread": "7.12.1", + "@babel/plugin-syntax-jsx": "7.12.1", + "@mdx-js/util": "1.6.22", + "is-alphabetical": "1.0.4", + "remark-parse": "8.0.3", + "unified": "9.2.0" + }, + "dependencies": { + "@babel/core": { + "version": "7.12.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", + "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.5", + "@babel/helper-module-transforms": "^7.12.1", + "@babel/helpers": "^7.12.5", + "@babel/parser": "^7.12.7", + "@babel/template": "^7.12.7", + "@babel/traverse": "^7.12.9", + "@babel/types": "^7.12.7", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz", + "integrity": "sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-transform-parameters": "^7.12.1" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz", + "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + } + } + }, + "remark-parse": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz", + "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==", + "requires": { + "ccount": "^1.0.0", + "collapse-white-space": "^1.0.2", + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-whitespace-character": "^1.0.0", + "is-word-character": "^1.0.0", + "markdown-escapes": "^1.0.0", + "parse-entities": "^2.0.0", + "repeat-string": "^1.5.4", + "state-toggle": "^1.0.0", + "trim": "0.0.1", + "trim-trailing-lines": "^1.0.0", + "unherit": "^1.0.4", + "unist-util-remove-position": "^2.0.0", + "vfile-location": "^3.0.0", + "xtend": "^4.0.1" + } + }, + "remark-squeeze-paragraphs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz", + "integrity": "sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw==", + "requires": { + "mdast-squeeze-paragraphs": "^4.0.0" + } + }, + "renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "requires": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, + "require-like": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", + "integrity": "sha1-rW8wwTvs15cBDEaK+ndcDAprR/o=" + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, + "resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "requires": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + }, + "resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, + "responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "requires": { + "lowercase-keys": "^1.0.0" + } + }, + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, + "rtl-detect": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.0.4.tgz", + "integrity": "sha512-EBR4I2VDSSYr7PkBmFy04uhycIpDKp+21p/jARYXlCSjQksTBQcJ0HFUPOO79EPPH5JS6VAhiIQbycf0O3JAxQ==" + }, + "rtlcss": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-3.5.0.tgz", + "integrity": "sha512-wzgMaMFHQTnyi9YOwsx9LjOxYXJPzS8sYnFaKm6R5ysvTkwzHiB0vxnbHwchHQT65PTdBjDG21/kQBWI7q9O7A==", + "requires": { + "find-up": "^5.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.3.11", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "requires": { + "p-locate": "^5.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "requires": { + "p-limit": "^3.0.2" + } + } + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "rxjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz", + "integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==", + "requires": { + "tslib": "^2.1.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "requires": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + } + }, + "section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "requires": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + } + }, + "select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=" + }, + "selfsigned": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.1.tgz", + "integrity": "sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ==", + "requires": { + "node-forge": "^1" + } + }, + "semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "semver-diff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", + "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "requires": { + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + } + } + }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "requires": { + "randombytes": "^2.1.0" + } + }, + "serve-handler": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.3.tgz", + "integrity": "sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w==", + "requires": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "fast-url-parser": "1.1.3", + "mime-types": "2.1.18", + "minimatch": "3.0.4", + "path-is-inside": "1.0.2", + "path-to-regexp": "2.2.1", + "range-parser": "1.2.0" + }, + "dependencies": { + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "path-to-regexp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz", + "integrity": "sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==" + } + } + }, + "serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=", + "requires": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + } + } + }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "requires": { + "kind-of": "^6.0.2" + } + }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "shell-quote": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==" + }, + "shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "sirv": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.19.tgz", + "integrity": "sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==", + "requires": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^1.0.0" + } + }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, + "sitemap": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-7.1.1.tgz", + "integrity": "sha512-mK3aFtjz4VdJN0igpIJrinf3EO8U8mxOPsTBzSsy06UtjZQJ3YY3o3Xa7zSc5nMqcMrRwlChHZ18Kxg0caiPBg==", + "requires": { + "@types/node": "^17.0.5", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.2.4" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" + }, + "sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "requires": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "sort-css-media-queries": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sort-css-media-queries/-/sort-css-media-queries-2.0.4.tgz", + "integrity": "sha512-PAIsEK/XupCQwitjv7XxoMvYhT7EAfyzI3hsy/MyDgTvc+Ft55ctdkctJLOy6cQejaIC+zjpUL4djFVm2ivOOw==" + }, + "source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==" + }, + "spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "requires": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + } + }, + "spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "requires": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==" + }, + "state-toggle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz", + "integrity": "sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==" + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, + "std-env": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.1.1.tgz", + "integrity": "sha512-/c645XdExBypL01TpFKiG/3RAa/Qmu+zRi0MwAmrdEkwHNuN0ebo8ccAXBBDa5Z0QOJgBskUIbuCK91x0sCVEw==" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "requires": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==" + }, + "strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "requires": { + "ansi-regex": "^6.0.1" + } + } + } + }, + "stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "requires": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=" + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==" + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + }, + "style-to-object": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", + "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", + "requires": { + "inline-style-parser": "0.1.1" + } + }, + "stylehacks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.0.tgz", + "integrity": "sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==", + "requires": { + "browserslist": "^4.16.6", + "postcss-selector-parser": "^6.0.4" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" + }, + "svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "requires": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" + } + } + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" + }, + "terser": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.13.1.tgz", + "integrity": "sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA==", + "requires": { + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map": "~0.8.0-beta.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "requires": { + "whatwg-url": "^7.0.0" + } + }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "requires": { + "punycode": "^2.1.0" + } + }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + }, + "whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + } + } + }, + "terser-webpack-plugin": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz", + "integrity": "sha512-GvlZdT6wPQKbDNW/GDQzZFg/j4vKU96yl2q6mcUkzKOgW4gwf1Z8cZToUCrz31XHlPWH8MVb1r2tFtdDtTGJ7g==", + "requires": { + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1", + "terser": "^5.7.2" + }, + "dependencies": { + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=" + }, + "thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, + "tiny-invariant": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" + }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + }, + "to-readable-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==" + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "totalist": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz", + "integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==" + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, + "trim": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", + "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=" + }, + "trim-trailing-lines": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz", + "integrity": "sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==" + }, + "trough": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", + "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==" + }, + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + }, + "type-fest": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.12.2.tgz", + "integrity": "sha512-qt6ylCGpLjZ7AaODxbpyBZSs9fCI9SkL3Z9q2oxMBQhs/uyY+VD8jHA8ULCGmWQJlBgqvO3EJeAngOHD8zQCrQ==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "dependencies": { + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + } + } + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "peer": true + }, + "ua-parser-js": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", + "integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==" + }, + "unherit": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz", + "integrity": "sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==", + "requires": { + "inherits": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==" + }, + "unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "requires": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", + "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==" + }, + "unicode-property-aliases-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz", + "integrity": "sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==" + }, + "unified": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz", + "integrity": "sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==", + "requires": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + } + }, + "unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "requires": { + "crypto-random-string": "^2.0.0" + } + }, + "unist-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz", + "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==" + }, + "unist-util-generated": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz", + "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==" + }, + "unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==" + }, + "unist-util-position": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz", + "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==" + }, + "unist-util-remove": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-2.1.0.tgz", + "integrity": "sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q==", + "requires": { + "unist-util-is": "^4.0.0" + } + }, + "unist-util-remove-position": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz", + "integrity": "sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==", + "requires": { + "unist-util-visit": "^2.0.0" + } + }, + "unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "requires": { + "@types/unist": "^2.0.2" + } + }, + "unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "requires": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + } + }, + "unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "requires": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + } + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "update-notifier": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-5.1.0.tgz", + "integrity": "sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==", + "requires": { + "boxen": "^5.0.0", + "chalk": "^4.1.0", + "configstore": "^5.0.1", + "has-yarn": "^2.1.0", + "import-lazy": "^2.1.0", + "is-ci": "^2.0.0", + "is-installed-globally": "^0.4.0", + "is-npm": "^5.0.0", + "is-yarn-global": "^0.3.0", + "latest-version": "^5.1.0", + "pupa": "^2.1.1", + "semver": "^7.3.4", + "semver-diff": "^3.1.1", + "xdg-basedir": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "requires": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==" + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==" + }, + "widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "requires": { + "string-width": "^4.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "requires": { + "punycode": "^2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + } + } + }, + "url-loader": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", + "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", + "requires": { + "loader-utils": "^2.0.0", + "mime-types": "^2.1.27", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "url-parse-lax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "requires": { + "prepend-http": "^2.0.0" + } + }, + "use-composed-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", + "requires": {} + }, + "use-latest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.1.tgz", + "integrity": "sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==", + "requires": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "dependencies": { + "use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "requires": {} + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=" + }, + "utility-types": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz", + "integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "vfile": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", + "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "requires": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^2.0.0", + "vfile-message": "^2.0.0" + } + }, + "vfile-location": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-3.2.0.tgz", + "integrity": "sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==" + }, + "vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "requires": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + } + }, + "wait-on": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-6.0.1.tgz", + "integrity": "sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw==", + "requires": { + "axios": "^0.25.0", + "joi": "^17.6.0", + "lodash": "^4.17.21", + "minimist": "^1.2.5", + "rxjs": "^7.5.4" + } + }, + "watchpack": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", + "integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==", + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "requires": { + "minimalistic-assert": "^1.0.0" + } + }, + "web-namespaces": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz", + "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "webpack": { + "version": "5.72.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.72.1.tgz", + "integrity": "sha512-dXG5zXCLspQR4krZVR6QgajnZOjW2K/djHvdcRaDQvsjV9z9vaW6+ja5dZOYbqBBjF6kGXka/2ZyxNdc+8Jung==", + "requires": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^0.0.51", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.4.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.9.3", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.3.1", + "webpack-sources": "^3.2.3" + }, + "dependencies": { + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==" + } + } + }, + "webpack-bundle-analyzer": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz", + "integrity": "sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ==", + "requires": { + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "gzip-size": "^6.0.0", + "lodash": "^4.17.20", + "opener": "^1.5.2", + "sirv": "^1.0.7", + "ws": "^7.3.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "webpack-dev-middleware": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.1.tgz", + "integrity": "sha512-81EujCKkyles2wphtdrnPg/QqegC/AtqNH//mQkBYSMqwFVCQrxM6ktB2O/SPlZy7LqeEfTbV3cZARGQz6umhg==", + "requires": { + "colorette": "^2.0.10", + "memfs": "^3.4.1", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + } + } + }, + "webpack-dev-server": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.9.0.tgz", + "integrity": "sha512-+Nlb39iQSOSsFv0lWUuUTim3jDQO8nhK3E68f//J2r5rIcp4lULHXz2oZ0UVdEeWXEh5lSzYUlzarZhDAeAVQw==", + "requires": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.1", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^1.6.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.0.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.21", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.4.2" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + }, + "ws": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.6.0.tgz", + "integrity": "sha512-AzmM3aH3gk0aX7/rZLYvjdvZooofDu3fFOzGqcSnQ1tOcTWwhM/o+q++E8mAyVVIyUdajrkzWUGftaVSDLn1bw==", + "requires": {} + } + } + }, + "webpack-merge": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.8.0.tgz", + "integrity": "sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==", + "requires": { + "clone-deep": "^4.0.1", + "wildcard": "^2.0.0" + } + }, + "webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "webpackbar": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-5.0.2.tgz", + "integrity": "sha512-BmFJo7veBDgQzfWXl/wwYXr/VFus0614qZ8i9znqcl9fnEdiVkdbi0TedLQ6xAK92HZHDJ0QmyQ0fmuZPAgCYQ==", + "requires": { + "chalk": "^4.1.0", + "consola": "^2.15.3", + "pretty-time": "^1.1.0", + "std-env": "^3.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "requires": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "requires": { + "isexe": "^2.0.0" + } + }, + "widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "requires": { + "string-width": "^5.0.1" + } + }, + "wildcard": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", + "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==" + }, + "wrap-ansi": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.0.1.tgz", + "integrity": "sha512-QFF+ufAqhoYHvoHdajT/Po7KoXVBPXS2bgjIam5isfWJPfIOnQZ50JtUiVvCv/sjgacf3yRrt2ZKUZ/V4itN4g==", + "requires": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==" + }, + "ansi-styles": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.1.0.tgz", + "integrity": "sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==" + }, + "strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "requires": { + "ansi-regex": "^6.0.1" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "ws": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", + "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", + "requires": {} + }, + "xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" + }, + "xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "requires": { + "sax": "^1.2.4" + } + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + "zwitch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", + "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==" + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..a499102b --- /dev/null +++ b/docs/package.json @@ -0,0 +1,37 @@ +{ + "name": "website", + "version": "0.0.0", + "private": true, + "scripts": { + "docusaurus": "docusaurus", + "start": "docusaurus start", + "build": "docusaurus build", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy", + "clear": "docusaurus clear", + "serve": "docusaurus serve", + "write-translations": "docusaurus write-translations", + "write-heading-ids": "docusaurus write-heading-ids" + }, + "dependencies": { + "@docusaurus/core": "^0.0.0-4999", + "@docusaurus/preset-classic": "^0.0.0-4999", + "@mdx-js/react": "^1.6.22", + "clsx": "^1.1.1", + "prism-react-renderer": "^1.3.1", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "browserslist": { + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/docs/sidebars.js b/docs/sidebars.js new file mode 100644 index 00000000..fd342f2c --- /dev/null +++ b/docs/sidebars.js @@ -0,0 +1,31 @@ +/** + * Creating a sidebar enables you to: + - create an ordered group of docs + - render a sidebar for each doc of that group + - provide next/previous navigation + + The sidebars can be generated from the filesystem, or explicitly defined here. + + Create as many sidebars as you want. + */ + +// @ts-check + +/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ +const sidebars = { + // By default, Docusaurus generates a sidebar from the docs folder structure + tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], + + // But you can create a sidebar manually + /* + tutorialSidebar: [ + { + type: 'category', + label: 'Tutorial', + items: ['hello'], + }, + ], + */ +}; + +module.exports = sidebars; diff --git a/docs/src/components/HomepageFeatures/index.js b/docs/src/components/HomepageFeatures/index.js new file mode 100644 index 00000000..eb7b6e7e --- /dev/null +++ b/docs/src/components/HomepageFeatures/index.js @@ -0,0 +1,64 @@ +import React from "react"; +import clsx from "clsx"; +import styles from "./styles.module.css"; + +const FeatureList = [ + { + title: "Everything you need", + Svg: require("@site/static/img/product-dev.svg").default, + description: ( + <> + Learn Flask, Docker, PostgreSQL, and more. Build professional-grade REST + APIs with Python. + + ), + }, + { + title: "The latest versions", + Svg: require("@site/static/img/cloud-download.svg").default, + description: ( + <> + No more outdated tutorials. Use Python 3.10+ and the latest versions of + every Flask extension and library. + + ), + }, + { + title: "Use best practices", + Svg: require("@site/static/img/robot-coding.svg").default, + description: ( + <> + Run your apps in Docker, host your code with Git, write documentation + with Swagger, and test your APIs while developing. + + ), + }, +]; + +function Feature({ Svg, title, description }) { + return ( +
+
+ +
+
+

{title}

+

{description}

+
+
+ ); +} + +export default function HomepageFeatures() { + return ( +
+
+
+ {FeatureList.map((props, idx) => ( + + ))} +
+
+
+ ); +} diff --git a/docs/src/components/HomepageFeatures/styles.module.css b/docs/src/components/HomepageFeatures/styles.module.css new file mode 100644 index 00000000..b248eb2e --- /dev/null +++ b/docs/src/components/HomepageFeatures/styles.module.css @@ -0,0 +1,11 @@ +.features { + display: flex; + align-items: center; + padding: 2rem 0; + width: 100%; +} + +.featureSvg { + height: 200px; + width: 200px; +} diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css new file mode 100644 index 00000000..1190216f --- /dev/null +++ b/docs/src/css/custom.css @@ -0,0 +1,48 @@ +/** + * Any CSS included here will be global. The classic template + * bundles Infima by default. Infima is a CSS framework designed to + * work well for content-centric websites. + */ + +/* You can override the default Infima variables here. */ +:root { + --ifm-color-primary: #2e8555; + --ifm-color-primary-dark: #29784c; + --ifm-color-primary-darker: #277148; + --ifm-color-primary-darkest: #205d3b; + --ifm-color-primary-light: #33925d; + --ifm-color-primary-lighter: #359962; + --ifm-color-primary-lightest: #3cad6e; + --ifm-code-font-size: 90%; + --ifm-code-padding-horizontal: 0.3rem; + --ifm-code-padding-vertical: 0.15rem; + --ifm-code-border-radius: 5px; +} + +/* For readability concerns, you should choose a lighter palette in dark mode. */ +[data-theme='dark'] { + --ifm-color-primary: #25c2a0; + --ifm-color-primary-dark: #21af90; + --ifm-color-primary-darker: #1fa588; + --ifm-color-primary-darkest: #1a8870; + --ifm-color-primary-light: #29d5b0; + --ifm-color-primary-lighter: #32d8b4; + --ifm-color-primary-lightest: #4fddbf; +} + +.docusaurus-highlight-code-line { + background-color: rgba(0, 0, 0, 0.1); + display: block; + margin: 0 calc(-1 * var(--ifm-pre-padding)); + padding: 0 var(--ifm-pre-padding); +} + +[data-theme='dark'] .docusaurus-highlight-code-line { + background-color: rgba(0, 0, 0, 0.3); +} + +.codeTabContainer { + padding: 1rem; + background-color: rgba(46, 133, 85, 0.15); + border-radius: 8px; +} \ No newline at end of file diff --git a/docs/src/pages/index.js b/docs/src/pages/index.js new file mode 100644 index 00000000..7209b8a0 --- /dev/null +++ b/docs/src/pages/index.js @@ -0,0 +1,42 @@ +import React from "react"; +import clsx from "clsx"; +import Layout from "@theme/Layout"; +import Link from "@docusaurus/Link"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import styles from "./index.module.css"; +import HomepageFeatures from "@site/src/components/HomepageFeatures"; + +function HomepageHeader() { + const { siteConfig } = useDocusaurusContext(); + return ( +
+
+

{siteConfig.title}

+

{siteConfig.tagline}

+
+ + Read the e-book + +
+
+
+ ); +} + +export default function Home() { + const { siteConfig } = useDocusaurusContext(); + return ( + + +
+ +
+
+ ); +} diff --git a/docs/src/pages/index.module.css b/docs/src/pages/index.module.css new file mode 100644 index 00000000..9f71a5da --- /dev/null +++ b/docs/src/pages/index.module.css @@ -0,0 +1,23 @@ +/** + * CSS files with the .module.css suffix will be treated as CSS modules + * and scoped locally. + */ + +.heroBanner { + padding: 4rem 0; + text-align: center; + position: relative; + overflow: hidden; +} + +@media screen and (max-width: 996px) { + .heroBanner { + padding: 2rem; + } +} + +.buttons { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/docs/src/pages/markdown-page.md b/docs/src/pages/markdown-page.md new file mode 100644 index 00000000..9756c5b6 --- /dev/null +++ b/docs/src/pages/markdown-page.md @@ -0,0 +1,7 @@ +--- +title: Markdown page example +--- + +# Markdown page example + +You don't need React to write simple standalone pages. diff --git a/docs/static/img/cloud-download.svg b/docs/static/img/cloud-download.svg new file mode 100644 index 00000000..1aa9e31e --- /dev/null +++ b/docs/static/img/cloud-download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico new file mode 100644 index 00000000..239b3256 Binary files /dev/null and b/docs/static/img/favicon.ico differ diff --git a/docs/static/img/product-dev.svg b/docs/static/img/product-dev.svg new file mode 100644 index 00000000..32b70963 --- /dev/null +++ b/docs/static/img/product-dev.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/static/img/robot-coding.svg b/docs/static/img/robot-coding.svg new file mode 100644 index 00000000..d67bda36 --- /dev/null +++ b/docs/static/img/robot-coding.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/static/img/undraw_docusaurus_mountain.svg b/docs/static/img/undraw_docusaurus_mountain.svg new file mode 100644 index 00000000..af961c49 --- /dev/null +++ b/docs/static/img/undraw_docusaurus_mountain.svg @@ -0,0 +1,171 @@ + + Easy to Use + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/undraw_docusaurus_react.svg b/docs/static/img/undraw_docusaurus_react.svg new file mode 100644 index 00000000..94b5cf08 --- /dev/null +++ b/docs/static/img/undraw_docusaurus_react.svg @@ -0,0 +1,170 @@ + + Powered by React + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/undraw_docusaurus_tree.svg b/docs/static/img/undraw_docusaurus_tree.svg new file mode 100644 index 00000000..d9161d33 --- /dev/null +++ b/docs/static/img/undraw_docusaurus_tree.svg @@ -0,0 +1,40 @@ + + Focus on What Matters + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/guides/DigitalOcean Tutorial.md b/guides/DigitalOcean Tutorial.md deleted file mode 100644 index 2f3c4d22..00000000 --- a/guides/DigitalOcean Tutorial.md +++ /dev/null @@ -1,65 +0,0 @@ -# Overview - -This is a tutorial on setting up a server on DigitalOcean, a cloud computing platform. We will walk through whole process from creating an account to setting up a server instance and connecting to it. The process should be similar for any cloud computing services. Once you set up your server on any platform of your choice the remaining deploying steps should be identical. Here is a tutorial that may serve as a general guide on deploying your Python app onto any hosting platforms: [How to Deploy Python App Using uWSGI and Nginx](https://github.com/tecladocode/rest-apis-flask-python/blob/master/guides/How%20To%20Deploy%20Python%20App%20Using%20uWSGI%20And%20Nginx.pdf). - -DigitalOcean is a cloud infrastructure provider focused on simplifying web infrastructure for software developers. It allows you to rent servers with different performance at different cost. For more detailed information, you may refer to the official website help page [here](https://www.digitalocean.com/help/). - -## Creating an account - -You can sign up to DigitalOcean using our affiliate link. Doing so gives you a starting credit of $100 to spend in 60 days. Click this link to create your account and get the $100: [https://m.do.co/c/d54c088544ed](https://m.do.co/c/d54c088544ed). If the link doesn't work, paste it into your browser. - -![Create an account](assets/DigitalOcean/create_account.png) - -After clicking the link, you should see a page like the above. Create your account at the left-bottom corner and you'll receive the $100 automatically. Beware that you'll be asked to provide payment info when creating the account, since all services (which you'll choose below) in DigitalOcean will be charged after your credit runs out. - -## Creating a Droplet - -A server instance in DigitalOcean is called a `Droplet`. It's just a name that may vary in different platforms, for example, `Dyno` for `Heroku` and `EC2` for `AWS` (Amazon Web Service). Below are the steps to create a `Droplet`. - -### Choosing an image - -To create a `Droplet`, we must first specify an image, that is, choosing what Operating System you want for the server. We recommend to use a Ubuntu LTS (Long Term Support) distribution. For more info on Ubuntu life time, please refer to the [official Ubuntu end of life page here](https://www.ubuntu.com/info/release-end-of-life). In our example, we'll use `Ubuntu 20.04 (LTS) x64`, which is an LTS distribution. - -### Choosing a size - -Next, we need to choose the specs for our server. In this tutorial, we'll be using the most basic tier of a Standard `Droplet`, which offers a single Regular Intel CPU with 1GB RAM, 25GB SSD and 1000 GB transfer at $5 per month. Generally, it's more than enough for running personal applications. You may also run several services in a single Droplet. - -### Choosing a datacenter region - -Generally, choosing a region that's _closest to your users_ will make your service deliver faster. If your users are primarily in the United States, you could choose a United States-based Droplet. - -### Other configurations - -In our example, we do not need to add any other services such as block storage or private network. So we will ignore these settings to keep our setup simple and cheap. -You may choose to use `SSH` key and follow the steps recommended by DigitalOcean or create a password. - -#### Setting up a password - -If you want to use a password you just have to select the __"Create a root password to access Droplet"__ and then insert a strong password which you can use to login remotely or through DigitalOcean's Droplet Console. - -#### Setting up an SSH key - - If you choose to use SSH key, then each time you want to login to the server from outside DigitalOcean's site, you will need to provide the key. For example in windows using [GitBash terminal](https://git-scm.com/downloads) you need to add the `-i` option and pass the full path for the ssh-key file: - -```bash -ssh root@insert-droplet-ip -i /c/Users/insert-username/.ssh/insert-ssh-key-filename -``` - -Be aware that this command will only work for the root user, if you want to add an ssh key for another user you can see the details in [DigitalOcean's docs](https://docs.digitalocean.com/products/droplets/how-to/add-ssh-keys/). - -Since you have to have access to the SSH key whenever you log in, it can be more secure or more troublesome depending on the scenario. -At last, you may change the name of your `Droplet` to something you like and then click `Create` to create and launch your `Droplet`. - -## Connecting to our droplet - -![DigitalOcean Access Console](assets/DigitalOcean/access_console.png) - -Once you've created your `Droplet` click the "Access Console" option in the dropdown menu associated with your `Droplet`, as shown in above image, and click the `Launch Droplet Console` button. - -If you have successfully followed the tutorial so far, then you have finished all the setting-ups that are specific to DigitalOcean. The following sections can serve as a standalone tutorial and can be applied to deployment onto any other platforms as well. - -## Deploying application onto our server - -After setting up our server (Droplet), the next thing we may want to do is to deploy our application onto the server. We will not, however, cover the process in this tutorial. Instead we recommend you to read this separate tutorial: [How to Deploy Python App Using uWSGI and Nginx](https://github.com/tecladocode/rest-apis-flask-python/blob/master/guides/How%20To%20Deploy%20Python%20App%20Using%20uWSGI%20And%20Nginx.pdf). We organize contents in such way because deployment is an independent process and should be similar on any platforms, not specific to DigitalOcean. So if you are curious, please check it out. - -Thanks for reading! diff --git a/guides/DigitalOcean Tutorial.pdf b/guides/DigitalOcean Tutorial.pdf deleted file mode 100644 index 0ca1d675..00000000 Binary files a/guides/DigitalOcean Tutorial.pdf and /dev/null differ diff --git a/guides/Flask-JWT Configuration Tutorial.md b/guides/Flask-JWT Configuration Tutorial.md deleted file mode 100644 index a1bb1964..00000000 --- a/guides/Flask-JWT Configuration Tutorial.md +++ /dev/null @@ -1,138 +0,0 @@ -# Flask-JWT Configuration Tutorial - -Flask-JWT adds JWT functionality to Flask in an easy to use manner. It gives you a lot of functionality out of the box, but sometimes we want to modify some of the configuration. This document walks through how to: - -* Change the authentication endpoint (by default, `/auth`); -* Change the token expiration time (by default, `5 minutes`); -* Change the authentication key name (by default, `username`). -* Change the authentication response body (by default, only contains `access_token`). - -In addition, it covers how to retrieve the **currently logged in user** from any of our Flask app endpoints. - -This tutorial assumes that you’ve followed the lectures and have set up Flask-JWT already! If you haven't done so yet, check out Section 5 of the [Udemy course](https://go.tecla.do/rest-apis-sale). - -## Before We Start - -First, let’s take a look at what we already have here. -In our app.py file, we should already set up the JWT using the below code: - -```python -from flask_jwt import JWT -from security import authenticate, identity - -jwt = JWT(app, authenticate, identity) # /auth -``` - -And in our security.py file, we should have something like this: - -```python -from hmac import compare_digest -from models.user import UserModel - -def authenticate(username, password): - user = UserModel.find_by_username(username) - if user and compare_digest(user.password, password): - return user - -def identity(payload): - user_id = payload['identity'] - return UserModel.find_by_id(user_id) -``` - -## Configuration - -### Authentication URL - -If we want to change the url to the authentication endpoint, for instance, we want to use `/login` instead of `/auth`, we can do something like this: - -```python -app.config['JWT_AUTH_URL_RULE'] = '/login' -jwt = JWT(app, authenticate, identity) -``` - -**Important**: We added the second line of code to emphasize that we must change the JWT authentication URL first, **before creating the `JWT` instance**. Otherwise, our configuration won't take effect. However, it is only required for configuring the auth URL, the following confurations will still take effect after requesting the `JWT` instance. - -### Token Expiration Time - -```python -# config JWT to expire within half an hour -app.config['JWT_EXPIRATION_DELTA'] = timedelta(seconds=1800) -``` - -### Authentication Key Name - -```python -# config JWT auth key name to be 'email' instead of default 'username' -app.config['JWT_AUTH_USERNAME_KEY'] = 'email' -``` - -### Authentication Response Handler - -Sometimes we may want to include more information in the authentication response body, not just the `access_token`. For example, we may also want to include the user's ID in the response body. In this case, we can do something like this: - -```python -# customize JWT auth response, include user_id in response body -from flask import jsonify -from flask_jwt import JWT - -from security import authenticate, identity as identity_function -jwt = JWT(app, authenticate, identity_function) - -@jwt.auth_response_handler -def customized_response_handler(access_token, identity): - return jsonify({ - 'access_token': access_token.decode('utf-8'), - 'user_id': identity.id - }) -``` - -Remember that the `identity` should be what you've returned by the `authenticate()` function, and in our sample, it is a `UserModel` object which contains a field `id`. Make sure to only access valid fields in your `identity` model! - -Moreover, it is generally not recommended to include information that is encrypted in the `access_token` since it may introduce security issues. - -### Error handler - -By default, Flask-JWT raises `JWTError` when an error occurs within any of the handlers (e.g. during authentication, identity, or creating the response). In some cases we may want to customize what our Flask app does when such an error occurs. We can do it this way: - -```python -# customize JWT auth response, include user_id in response body -from flask import jsonify -from flask_jwt import JWT - -from security import authenticate, identity as identity_function -jwt = JWT(app, authenticate, identity_function) - -@jwt.error_handler -def customized_error_handler(error): - return jsonify({ - 'message': error.description, - 'code': error.status_code - }), error.status_code -``` - -### Other Configurations - -You may find out more configuration options here: [https://pythonhosted.org/Flask-JWT/](https://pythonhosted.org/Flask-JWT/) - -Please refer to the [Configuration Options](https://pythonhosted.org/Flask-JWT/#configuration-options) section. - -## More - -### Retrieving User From Token - -Another frequently asked question is: *how can I get the user's identity from an access token (JWT)?* Since in some cases, we not only want to guarantee that only our users can access an endpoint, but we may want to access the user's data as well. For example, if you want to restrict the access to a certain user group, not for every user. In this case, you can do something like this: - -```python -from flask_jwt import jwt_required, current_identity - - -class User(Resource): - - @jwt_required() - def get(self): # view all users - user = current_identity - # then implement admin auth method - ... -``` - -Now this endpoint is protected by JWT. And you have access to the identity of the user who is interacting with this endpoint using `current_identity` from JWT. diff --git a/guides/Flask-JWT Configuration Tutorial.pdf b/guides/Flask-JWT Configuration Tutorial.pdf deleted file mode 100644 index 225471ea..00000000 Binary files a/guides/Flask-JWT Configuration Tutorial.pdf and /dev/null differ diff --git a/guides/Heroku Tutorial.md b/guides/Heroku Tutorial.md deleted file mode 100644 index 4ae6e7f9..00000000 --- a/guides/Heroku Tutorial.md +++ /dev/null @@ -1,173 +0,0 @@ -# Overview - -In this tutorial, we will introduce a popular cloud application platform called Heroku. We will briefly describe [what Heroku is](#what-is-heroku) and [how to use it](#deploying-our-app-onto-heroku). - -# What is Heroku - -Heroku is a cloud platform for deploying and running modern apps. A more detailed Introduction can be found [here](https://www.heroku.com/platform). - -Some of the key advantages of Heroku include: -- No server side knowledge is required, so it is easy to set up. -- It can be free, but the free tier has some limitations, which we will discuss later when we introduce [`Dyno`](#creating-a-heroku-dyno). -- It enables SSL so the communication between your app and its users are encrypted. - - -# Deploying our app onto Heroku - -In this section, we will describe in length on how to deploy our app onto Heroku. - -## Preparing our code - -Before we start deploying our app into Heroku, we need to make some preparation: -- We will be using GitHub to store our application source code and make it available for Heroku. -- We need to add a few files to allow Heroku to understand and run our application. - -We will explain exactly how to do it in this section. - -### Getting our sample app from GitHub - -In this tutorial, we assume you know the basics on Git and GitHub, and that you have experience in setting up a GitHub repository and push your code onto it. If you have no idea what Git or GitHub is about, do not worry. [Here is a good video tutorial on how to use Git and GitHub](https://www.youtube.com/watch?v=SWYqp7iY_Tc). - -If you wished to follow this tutorial using our sample project, a `Flask` REST API, you may `fork` this repo to your own GitHub account: [https://github.com/schoolofcode-me/stores-rest-api](https://github.com/schoolofcode-me/stores-rest-api) - -The above repo should already contain the files needed for Heroku deployment, but we will still cover them in the tutorial so that you know what to do if you want to deploy your own application. - -### Adding files for Heroku - -In order to let Heroku understand our project, we need to add some files to tell Heroku how to run this application. - -#### runtime.txt - -The first file we need to add is called `runtime.txt`. And it is **important** that we have to name the file this way so that Heroku knows where to look at. All the filenames should remain unchanged for the following files we introduce unless specified otherwise. The `runtime.txt` file should contain what language and version you are using for the application. So for our sample project, our `runtime.txt` content should look like this: - -``` -python-3.5.2 -``` - -#### requirements.txt - -The next file we need to add is called `requirements.txt`, which specifies all the dependencies of our project. So for our sample project, we will have something like this for the `requirements.txt` file: - -``` -Flask -Flask-RESTful -Flask-JWT -Flask-SQLAlchemy -uwsgi -psycopg2 -``` - -Note that we are running a `Python` project, and `uWSGI` is used to run `Python` applications more efficiently and more reliably, providing features such as multi-threading and auto restarting after failure. If you are not running a `Python` app or you choose not to use `uWSGI`, it is totally fine and you can skip the next sub-section and go to the `Procfile` sub-section [here](#procfile). - -#### uwsgi.ini \* - -The `uwsgi.ini` file is only necessary if you are running a `Python` app and choose to use `uWSGI`, which is highly recommended in this case. For our sample project, we have a `uwsgi.ini` file that looks like this: - -``` -[uwsgi] -http-socket = :$(PORT) -master = true -die-on-term = true -module = run:app -memory-report = true -``` - -The `uwsgi.ini` file is used to tell `uWSGI` how to create and run a service. We will explain line by line about this file since it is confusing for many learners: -- Specify a uWSGI section -- Specify the port number used for the connection. However, we do not need to explicitly specify a port number, it will read `PORT` from Heroku. -- Indicate that there is a `master` process as opposed to `slave` processes. -- When the process is terminated, the uWSGI process will be killed to free up resources. -- Specify what `module` we are running. In our sample project, we used the `app` module from the file `run.py`. So your config for this property should follow the format `module =:`. -- Do create memory report. - -Another useful property is the `pythonpath` property. If your code is not in the root folder, but inside some nested folder instead, you can specify the path to your code here. For example, if we put our code in `./code` folder, we may use: - -``` -pythonpath = ./code -``` - -#### Procfile - -The last file we need to add is called `Procfile`. It will tell Heroku how to run our app. In our case, we will be using `uWSGI` to run the app, so our `Procfile` should look like this: - -``` -web: uwsgi uwsgi.ini -``` - -And this is all the preparation we need in our project for Heroku. Remember to `push` these changes to your GitHub repository. - -## Setting up Heroku for our app - -### Creating a Heroku account - -First thing we need to do is to sign up for a Heroku account. If you haven't done so, [here is the link](https://signup.heroku.com/). - -The page may ask you for your country. It should reflect your billing address if you decided to upgrade to paid tier servers. - -The page will also ask for your primary programming language, which will not have any impact on your project. - -Beware that Heroku might ask you for your payment information, however, if you are following this tutorial, you will be using the free tier service and thus not be charged. - -### Creating a Heroku Dyno - -After creating and logging into your account, you will be ask to select a `Dyno`, which is essential a server in `Heroku`. Other platforms will have different names for their servers. Remember to select the free tier `Dyno` unless you choose to pay. - -The free tier `Dyno` is powerful enough to test your project, and the limitation is that it will go to sleep if no requests come in 30 minutes. It will wake up in a few seconds after receiving a request when it's sleeping. The performance of free `Dyno` is also limited. Still, it's a good way to test our project. - -### Creating a Heroku app - -After selecting the `Dyno`, you will be navigated to the Dashboard, where you can create a new app. Click the `New` button on the upper-right of your Dashboard, and select `create new app` from the dropdown menu as shown in the image below: - - - -Then enter the app name and select a region for your app. Selecting a region which is closest to your users can make your service much faster. - -### Deploying our app - -After creating a new app, you will be navigated to the app page. - -Select the `Settings` tab and go to the `buildpack` option. Use the `Add buildpack` button and add the required buildpack for your project. For our sample project, we choose the Python buildpack: - - - -Next, select the `Deploy` tab in the app page: - - - -Then click the `GitHub` option in `Deployment method` section and enter your credentials to connect Heroku with your GitHub. Next, select the repository and branch (select `master` if you haven't created any other branches) where your project is. Once it is done, you can directly deploy your app from GitHub onto Heroku by clicking the button `Deploy Branch`. Heroku will take care of the rest for you from now on. After a few seconds, Heroku will finish deploying for you: - - - -And this is how simple to use Heroku! From now on, whenever you modified your code and put them onto GitHub, you can always deploy the new version with a single click on the `Deploy Branch` button. You may also enable `Automatic Deployment` feature, which will deploy as soon as you update the GitHub branch. - -### Heroku add-ons - -Heroku also provides many useful tools as `Add-ons`, which allows user to integrate other services with the current application. Add-ons may or may not be free, so please check carefully when using them. - -In this tutorial, we will be demonstrating a `PostgreSQL` add-on, which is very useful and can be free. - -#### PostgreSQL add-on - -Select the `Resource` tab of your app page, and click `Find more add-ons`. You will go to the add-ons page. Search for `postgres` in the search bar, and hundreds of Postgres related add-ons will show up. In this tutorial, we recommend the `Heroku Postgres` add-on. - -We then install the add-on and it will ask us to choose an application to which to connect. And it will ask us to select a `plan`, and we may choose `Hobby Dev` which is a free plan. The free plan has the limitation of 10,000 rows of entry in the whole database, so the data size should not exceed this limit. - -#### Environment Variables - -Another thing we may need is to set environment variables, which is easy to set up is Heroku. Click the `Settings` tab in your app page and you can see the `Config Variables` option. You may put all your environment variables here, such as your secret keys and database urls. However, if you followed our previous section, and used the `Heroku Postgres`, it will automatically create some entry in this setting, including the `DATABASE_URL` - -### Heroku trouble shooting - -Although we have learnt how to deploy our app onto Heroku with a click, things may not turn out as expected all the time, so we need to find a way to figure out what went wrong in the process. We will explain exactly how to do it in this section. The tool we will be using is called `Heroku CLI` which stands for Command Line Interface, and we can use it to see the Heroku logs as well as other options. To download `Heroku CLI`, click [here](https://devcenter.heroku.com/articles/heroku-cli). - -Once you have set up the `Heroku CLI`, you can use the command below to see the logs and hopefully figure out where the problem is: - -``` -heroku logs --app= -``` - -If you are using the `Heroku CLI` for the first time on your device, it will ask for your credentials. - -# Conclusion - -The above tutorial should give you a very basic idea on what Heroku is and how to deploy your app onto Heroku. Thank you for reading! diff --git a/guides/Heroku Tutorial.pdf b/guides/Heroku Tutorial.pdf deleted file mode 100644 index f38ad972..00000000 Binary files a/guides/Heroku Tutorial.pdf and /dev/null differ diff --git a/guides/How To Deploy Python App Using uWSGI And Nginx.md b/guides/How To Deploy Python App Using uWSGI And Nginx.md deleted file mode 100644 index 72b967dd..00000000 --- a/guides/How To Deploy Python App Using uWSGI And Nginx.md +++ /dev/null @@ -1,475 +0,0 @@ -# Overview - -This tutorial covers the basic steps of deploying a Python application onto a public server using `uWSGI` and `nginx`. We will be using a sample project, a Flask REST API, for demonstration, however, the deployment process should remain similar for any other Python applications. Our sample project can be found here: [https://github.com/schoolofcode-me/stores-rest-api](https://github.com/schoolofcode-me/stores-rest-api). - -In this tutorial, we will not cover how to set up a server on any hosting platforms, however, if you are looking for such a tutorial, you may take a look at this one: [DigitalOcean Tutorial](DigitalOcean%20Tutorial.md), in which you will learn the basics on setting up a server from the beginning on a cloud hosting platform called `DigitalOcean`. The procedure should be similar for setting up server on other platforms as well, such as `AWS` (Amazon Web Service). - -# Quick links -In this tutorial, we will assume you have a server set up already, and we will introduce the deployment process in the following order: - -- [Connecting to the server using `SSH`](#connecting-to-our-server). -- [Creating and configuring a `UNIX` user](#creating-another-user). -- [Setting up `PostgreSQL` database](#configuring-postgres). -- [Getting project code from `GitHub`](#getting-code-from-github). -- [Configuring `uWSGI` for our project](#uwsgi). -- [Configuring `nginx` for our project](#nginx). - -If you are a first time learner, we highly recommend you to follow through the whole tutorial so that you can get familiar with it and may be less likely to run into error. However, if you are only looking for information on a specific subject, please feel free to use the above links to navigate to according sections. - -# Connecting to our server - -In order to connect to our server, we need to use a tool called `SSH` (Secure Shell). We can SSH our server using the command: - -``` -ssh root@ -``` - -You will be asked for the root password (or the SSH key if you have set it up previously). Beware that `SSH` command only works on `UNIX`, not on `Windows`. However, there are plenty of software that you can use to SSH from Windows, [PuTTy](http://www.putty.org/) is a popular choice: - - - -After connecting to our server and logging in as the `root` user, it is recommended to run the below command first to get all the available updates: - -``` -apt-get update -``` - -We can use the following command to install packages: - -``` -apt-get install -``` - -Note that this is a just an example to install different packages using one command, we will see real use cases in the following sections. - -# Creating another user - -Since the `root` user is the most powerful, essentially a root user can do everything on the server, so we may want to limit access to it to improve security. So in this section, we will create a new user and configure it to "act like" a `root` user but with certain limitations, and we will login as this user from then on. It is highly recommended to do so, but if you choose not to follow this practice and simply want to login as the `root` user anyway, you may click [here to skip to the next section](How%20To%20Deploy%20Python%20App%20Using%20uWSGI%20And%20Nginx.md#configuring-postgres). - -## Hello John Doe - -In this section, we will create a user named `johndoe`. You may choose any name you want, just remember to swap `johndoe` with your username for each command and configuration. We can create a new user `johndoe` with the following command: - -``` -adduser johndoe -``` - -You will be asked to enter and confirm the password for this user, and then provide some info about this user. Notice that you can leave the info sections blank if you want to. And if you entered unmatching passwords, just complete the info section and we can change the password later by using the command: - -``` -passwd johndoe -``` - -## Providing user with additional privilege - -Since we will be logging in as `johndoe` for most of the time in the future, we will want it to have some "extra power", that is, temporarily acting as a super user. To do this, we have to add the user to the sudoers group running the command: - -``` -usermod -aG sudo johndoe -``` - -# Configuring Postgres - -Postgres allows from the start a user to access a database with its own name. Thus we must: - -1. Create a `johndoe` user inside PostgreSQL. -2. Create a `johndoe` database in PostgreSQL. - -Because we have a `johndoe` user in our server, it will automatically have permission to access the `johndoe` user in Postgres, and will be able to access the `johndoe` database. - -## Installing PostgreSQL - -``` -apt-get install postgresql postgresql-contrib -``` - -## Creating a Postgres user - -We use the following command to switch to a super user in Postgres named `postgres` and use it to create a Postgres user: - -``` -sudo -i -u postgres -createuser johndoe -P -``` - -After inputting and confirming the password, we now have created a Postgres user. Remember that we use the same username `johndoe` to create the Postgres user, since by default, Postgres only allows the UNIX user with the same name as its Postgres user to interact with it. - -## Creating a PostgreSQL database for our user - -After having created the Postgres user, we use the command - -``` -createdb johndoe -``` - -to create a database also name `johndoe`. Now, our UNIX user `johndoe` can directly interact with the PostgreSQL database named `johndoe` using the command: - -``` -psql -``` - -## Some useful Postgres commands - -To see the current connection info, use: - -``` -\conninfo -``` - -To quit Postgres: - -``` -\q -``` - -Change to user johndoe - -``` -su johndoe -``` - -## Improve security on our PostgreSQL database - -However, notice that we've created a password for the Postgres user but never have to use it just because we used the same username in UNIX and Postgres. It is safer to require a password when connecting to the database. Use the below command to configure Postgres security options. - -``` -sudo vi /etc/postgresql/12/main/pg_hba.conf -``` - -Navigate to the bottom of the file, and we may see something like this: - -``` -# Database administrative login by Unix domain socket -local all postgres peer - -# TYPE DATABASE USER ADDRESS METHOD - -# "local" is for Unix domain socket connections only -local all all peer -# IPv4 local connections: -host all all 127.0.0.1/32 md5 -# IPv6 local connections: -host all all ::1/128 md5 -``` - -Change the line - -``` -local all all peer -``` - -to - -``` -local all all md5 -``` - -to enable password authentication. - -**Important:** SQLAlchemy will ***NOT*** work unless we do this modification. - -# Getting code from GitHub - -In this section, we will pull our code from `GitHub`, which integrates a popular VCS (Version Control System) called `Git`. `Git` is a very good tool to manage and access your code both locally and remotely. - -## Setting up our app folder - -First, we create a folder called `items-rest` for our app, since our sample project is a REST API which manages items of stores. We create this folder using the following command: - -``` -sudo mkdir -p /var/www/html/items-rest -``` - -The folder is owned by the `root` user since we used `sudo` to create it. We need to transfer ownership to our current user: - -``` -sudo chown johndoe:johndoe /var/www/html/items-rest -``` - -Remember that `johndoe` is the username in our tutorial, make sure you change it to yours accordingly. The same goes for `items-rest`. - -Next, we get our app from `Git`: - -``` -cd /var/www/html/items-rest/ -git clone https://github.com/schoolofcode-me/stores-rest-api.git . -``` - -Note that there's a trailing space and period (` .`) at the end, which tells `Git` the destination is the current folder. If you're not in this folder `/var/www/html/items-rest/`, remember to switch to it or explicitly specify it in the `Git` command. And for the following commands in this section, we all assume that we are inside the folder `/var/www/html/items-rest/` unless specified otherwise. - -In order to store logs, we need to create a log folder, (under `/var/www/html/items-rest/`): - -``` -mkdir log -``` - -Then we will install a bunch of tools we need to set up our app: - -``` -sudo apt-get install python3-pip python3-dev libpq-dev -``` - -Next, we will install `virtualenv`, which is a python library used to create virtual environment. Since we may want to deploy several services on one server in the future, using virtual environment allows us to create independent environment for each project so that their dependencies won't affect each other. We may install `virtualenv` using the following command: - -``` -sudo pip install virtualenv -``` - -After it is installed, we can create a `virtualenv`: - -``` -virtualenv venv --python=python3.8 -``` - -Note that `Ubuntu 20.04` usually comes with `Python3.8` and it is what we used in the sample code, if you choose to use different versions of `Python`, feel free to change it accordingly and it will be the Python version inside your `virtualenv`. - -To activate `virtualenv`: - -``` -source venv/bin/activate -``` - -You should see `(venv)` appears at the start of your command line now. We assume that we are in `virtualenv` for all the following commands in this section unless specified otherwise. - -Next, use the command below to install the specified dependencies: - -``` -pip install -r requirements.txt -``` - -`requirement.txt` is a text file that includes all the dependencies that we created in our `Git` folder. It's highly recommended to have a `requirements.txt` file with all libraries your project requires. - -Note that when installing these requirements, we are inside the virtual environment `venv`, so all the libraries are installed into `venv`, and once we quit `venv`, these libraries won't take effect. - -*Hint:* to quit virtual environment, use command: - -``` -deactivate -``` - -# uWSGI - -In this section, we will be using `uWSGI` to run the app for us, in this way, we can run our app in multiple threads within multiple processes. It also allow us to log more easily. More details on `uWSGI` can be found [here](https://uwsgi-docs.readthedocs.io/en/latest/). - -First, we define a `uWSGI` service in the system by: - -``` -sudo vi /etc/systemd/system/uwsgi_items_rest.service -``` - -And the content we are going to input is shown below: - -``` -[Unit] -Description=uWSGI items rest - -[Service] -Environment=DATABASE_URL=postgresql://johndoe:@localhost:5432/johndoe -ExecStart=/var/www/html/items-rest/venv/bin/uwsgi --master --emperor /var/www/html/items-rest/uwsgi.ini --die-on-term --uid johndoe --gid johndoe --logto /var/www/html/items-rest/log/emperor.log -Restart=always -KillSignal=SIGQUIT -Type=notify -NotifyAccess=all - -[Install] -WantedBy=multi-user.target -``` - -We will explain the basic idea of these configs. Each pair of square brackets `[]` defines a `section` which can contain some properties. - -The `Unit` section simply provides some basic description and can be helpful when looking at the logs. - -The `Service` section contains several properties related to our app. The `Environment` properties defines all the environment variables we need in our code. In our sample code, we want to retrieve the DATABASE_URL from system environment. And this is the place where you should keep all your secrets, such as secret keys and credentials. Beware that the `DATABASE_URL` should follow the format: - -``` -://:@localhost:/ -``` - -If we want to add multiple environment variables, we just need to add multiple lines of the `Environment` entry following the syntax: - -``` -Environment=key=value -Environment=key=value -Environment=key=value -... -``` - -The `ExecStart` property informs `uWSGI` on how to run our app as well as log it. - -At last, the `WantedBy` property in `Install` section allows the service to run as soon as the server boots up. - -**Important:** remember to change the username, password, database name and service name/folder accordingly in your own code. - -*Hint:* after editing the above file, press `ESC` to quit insert mode and use `:wq` to write and quit. - -## Configuring uWSGI - -Our next step is to configure `uWSGI` to run our app. To do so, we need to create a file named `uwsgi.ini` with the following content: - -``` -[uwsgi] -base = /var/www/html/items-rest -app = run -module = %(app) - -home = %(base)/venv -pythonpath = %(base) - -socket = %(base)/socket.sock - -chmod-socket = 777 - -processes = 8 - -threads = 8 - -harakiri = 15 - -callable = app - -logto = /var/www/html/items-rest/log/%n.log -``` - -Note that you should change the `base` folder accordingly in your own app. For the second entry, `run` is referred to the `run.py` in our sample app, which serves as the entry point of our app, so you may need to change it accordingly in your own project as well. We defined the `socket.sock` file here which will be required by the `nginx` later. The socket file will serve as the connection point between `nginx` and our `uWSGI` service. - -We asked for 8 processes with 8 threads each for no particular reason, you may adjust them according to your server capacity and data volume. The `harakiri` is a Japanese word for suicide, so in here it means for how long (in seconds) will the `emperor` kill the thread if it has failed. This is also an advantage we have with `uWSGI`, it allows our service to be resilient to minor failures. And it also specifies the log location. - -And at last, after saving the above file, we use the command below to run the `uWSGI` service we defined earlier: - -``` -sudo systemctl start uwsgi_items_rest -``` - -And we should be able to check the `uWSGI` logs immediately to make sure it's running by using the command: - -``` -vi /log/uwsgi.log -``` - -If anything is running normally, we should be seeing something like this: - - - -But if there is any error in our code, it will also be reflected in the log. - -# Nginx - -`Nginx` (engine x) is an HTTP and reverse proxy server, a mail proxy server, and a generic TCP/UDP proxy server. In this tutorial, we use `nginx` to direct traffic to our application. `Nginx` can be really helpful in scenarios like running our app on multiple threads, and it performs very well so we don't need to worry about it slowing down our app. More details about `nginx` can be found [here](https://nginx.org/en/). - -## Installing nginx - -``` -sudo apt-get install nginx -``` - -## Configure firewall to grant access to nginx - -First, check if the firewall is active: - -``` -sudo ufw status -``` - -If not, we will enable it later. Before that, let's add some new rules: - -``` -sudo ufw allow 'Nginx HTTP' -sudo ufw allow ssh -``` - -**Important:** the second line, adding SSH rules, is not related to `nginx` configuration, but since we're activating the firewall, we don't want to get blocked out of the server! - -If the UFW (Ubuntu Firewall) is inactive, use the command below to activate it: - -``` -sudo ufw enable -``` - -To check if `nginx` is running, use the command: - -``` -systemctl status nginx -``` - -Some other helpful command options for system controller are: - -``` -systemctl start -systemctl restart -systemctl reload -systemctl stop -``` - -## Configure nginx for our app - -Before deploying our app onto the server, we need to configure `nginx` for our app. Use the below command to create a config file for our app: - -``` -sudo vi /etc/nginx/sites-available/items-rest.conf -``` - -Note that `items-rest` is what we named our service, you may change it accordingly, but remember to remain consistent throughout the configurations. - -Next, we input the below text into `items-rest.conf` file. **Remember to change your service name accordingly in this file as well**. - -```nginx -server { - listen 80; - real_ip_header X-Forwarded-For; - set_real_ip_from 127.0.0.1; - server_name localhost; - - location / { - include uwsgi_params; - uwsgi_pass unix:/var/www/html/items-rest/socket.sock; - uwsgi_modifier1 30; - } - - error_page 404 /404.html; - location = /404.html { - root /usr/share/nginx/html; - } - - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } -} -``` - -The above config allows `nginx` to send the request coming from our user's browser to our app. It also sets up some error pages for our service using `nginx` predefined pages. - -And at last, in order to enable our configuration, we need to do something like this: - -``` -sudo rm /etc/nginx/sites-enabled/default -sudo ln -s /etc/nginx/sites-available/items-rest.conf /etc/nginx/sites-enabled/ -``` - -We need to remove the default file since `nginx` will look at this file by default. We want `nginx` to look at our config file instead, thus we added a soft link between our config file and the `site-enabled` folder. - -## Running our app - -Finally, we can launch our app! We can do so by starting the `nginx` and `uWSGI` services we defined (we already started the `uWSGI` service in the previous section). - -``` -sudo systemctl start nginx -``` - -If any of these services is already running, you may use the below commands (taking `nginx` for example) to reload and restart it so that it has the latest changes: - -``` -sudo systemctl reload nginx -sudo systemctl restart nginx -``` - -# Deployment wrap-up - -As the tutorial is very detailed, you may find it a bit hard to put the pieces together. Here's a quick wrap-up that may help you sort things out. - -- We created a `UNIX` user and granted him some privilege. -- We set up `PostgreSQL` database and configured our user to interact with it. -- We used `uWSGI` to run our app multi-processly and multi-threadly. -- We used `nginx` to direct requests to our `uWSGI` service. - -Thanks for reading! diff --git a/guides/How To Deploy Python App Using uWSGI And Nginx.pdf b/guides/How To Deploy Python App Using uWSGI And Nginx.pdf deleted file mode 100644 index 22b51561..00000000 Binary files a/guides/How To Deploy Python App Using uWSGI And Nginx.pdf and /dev/null differ diff --git a/guides/assets/DigitalOcean/access_console.png b/guides/assets/DigitalOcean/access_console.png deleted file mode 100644 index 2df3f889..00000000 Binary files a/guides/assets/DigitalOcean/access_console.png and /dev/null differ diff --git a/guides/assets/DigitalOcean/create_account.png b/guides/assets/DigitalOcean/create_account.png deleted file mode 100644 index 7970cf56..00000000 Binary files a/guides/assets/DigitalOcean/create_account.png and /dev/null differ diff --git a/guides/assets/Flask Deployment/putty.png b/guides/assets/Flask Deployment/putty.png deleted file mode 100644 index 8efff92a..00000000 Binary files a/guides/assets/Flask Deployment/putty.png and /dev/null differ diff --git a/guides/assets/Flask Deployment/uwsgi_log.png b/guides/assets/Flask Deployment/uwsgi_log.png deleted file mode 100644 index 8e92da9c..00000000 Binary files a/guides/assets/Flask Deployment/uwsgi_log.png and /dev/null differ diff --git a/guides/assets/Heroku/buildpack.png b/guides/assets/Heroku/buildpack.png deleted file mode 100644 index 7ee9a233..00000000 Binary files a/guides/assets/Heroku/buildpack.png and /dev/null differ diff --git a/guides/assets/Heroku/create_new_app.png b/guides/assets/Heroku/create_new_app.png deleted file mode 100644 index 3ed683f1..00000000 Binary files a/guides/assets/Heroku/create_new_app.png and /dev/null differ diff --git a/guides/assets/Heroku/deploy.png b/guides/assets/Heroku/deploy.png deleted file mode 100644 index ac9783a7..00000000 Binary files a/guides/assets/Heroku/deploy.png and /dev/null differ diff --git a/guides/assets/Heroku/select_tab.png b/guides/assets/Heroku/select_tab.png deleted file mode 100644 index 564cf375..00000000 Binary files a/guides/assets/Heroku/select_tab.png and /dev/null differ diff --git a/guides/assets/Heroku/settings.png b/guides/assets/Heroku/settings.png deleted file mode 100644 index cb1167a2..00000000 Binary files a/guides/assets/Heroku/settings.png and /dev/null differ diff --git a/project/01-first-rest-api/app.py b/project/01-first-rest-api/app.py new file mode 100644 index 00000000..1e75e84f --- /dev/null +++ b/project/01-first-rest-api/app.py @@ -0,0 +1,57 @@ +import uuid +from flask import Flask, request + +app = Flask(__name__) + +stores = {} +items = {} + + +@app.get("/item/") +def get_item(id): + try: + return items[id] + except KeyError: + return {"message": "Item not found"}, 404 + + +@app.post("/item") +def create_item(): + request_data = request.get_json() + new_item_id = uuid.uuid4().hex + new_item = { + "name": request_data["name"], + "price": request_data["price"], + "store_id": request_data["store_id"], + } + items[new_item_id] = new_item + return new_item + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.value())} + + +@app.get("/store/") +def get_store(id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[id] + except KeyError: + return {"message": "Store not found"}, 404 + + +@app.post("/store") +def create_store(): + request_data = request.get_json() + new_store_id = uuid.uuid4().hex + new_store = {"id": new_store_id, "name": request_data["name"]} + stores[new_store_id] = new_store + return new_store, 201 + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.value())} diff --git a/project/02-first-rest-api-docker/Dockerfile b/project/02-first-rest-api-docker/Dockerfile new file mode 100644 index 00000000..4d33d373 --- /dev/null +++ b/project/02-first-rest-api-docker/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +RUN pip install flask +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/project/02-first-rest-api-docker/app.py b/project/02-first-rest-api-docker/app.py new file mode 100644 index 00000000..1e75e84f --- /dev/null +++ b/project/02-first-rest-api-docker/app.py @@ -0,0 +1,57 @@ +import uuid +from flask import Flask, request + +app = Flask(__name__) + +stores = {} +items = {} + + +@app.get("/item/") +def get_item(id): + try: + return items[id] + except KeyError: + return {"message": "Item not found"}, 404 + + +@app.post("/item") +def create_item(): + request_data = request.get_json() + new_item_id = uuid.uuid4().hex + new_item = { + "name": request_data["name"], + "price": request_data["price"], + "store_id": request_data["store_id"], + } + items[new_item_id] = new_item + return new_item + + +@app.get("/item") +def get_all_items(): + return {"items": list(items.value())} + + +@app.get("/store/") +def get_store(id): + try: + # Here you might also want to add the items in this store + # We'll do that later on in the course + return stores[id] + except KeyError: + return {"message": "Store not found"}, 404 + + +@app.post("/store") +def create_store(): + request_data = request.get_json() + new_store_id = uuid.uuid4().hex + new_store = {"id": new_store_id, "name": request_data["name"]} + stores[new_store_id] = new_store + return new_store, 201 + + +@app.get("/store") +def get_stores(): + return {"stores": list(stores.value())} diff --git a/project/03-items-stores-smorest/.flaskenv b/project/03-items-stores-smorest/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/project/03-items-stores-smorest/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/project/03-items-stores-smorest/Dockerfile b/project/03-items-stores-smorest/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/project/03-items-stores-smorest/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/project/03-items-stores-smorest/app.py b/project/03-items-stores-smorest/app.py new file mode 100644 index 00000000..5afd6e7b --- /dev/null +++ b/project/03-items-stores-smorest/app.py @@ -0,0 +1,21 @@ +from flask import Flask +from flask_smorest import Api + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +app = Flask(__name__) + +app.config["PROPAGATE_EXCEPTIONS"] = True +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +api = Api(app) + +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) diff --git a/project/03-items-stores-smorest/db.py b/project/03-items-stores-smorest/db.py new file mode 100644 index 00000000..92616e7a --- /dev/null +++ b/project/03-items-stores-smorest/db.py @@ -0,0 +1,12 @@ +""" +db.py +--- + +Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. +Our data storage is: + - stores have a unique ID and a name + - items have a unique ID, a name, a price, and a store ID. +""" + +stores = {} +items = {} diff --git a/project/03-items-stores-smorest/requirements.txt b/project/03-items-stores-smorest/requirements.txt new file mode 100644 index 00000000..4764bf34 --- /dev/null +++ b/project/03-items-stores-smorest/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-smorest +python-dotenv +marshmallow \ No newline at end of file diff --git a/project/03-items-stores-smorest/resources/__init__.py b/project/03-items-stores-smorest/resources/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/project/03-items-stores-smorest/resources/__init__.py @@ -0,0 +1 @@ + diff --git a/project/03-items-stores-smorest/resources/item.py b/project/03-items-stores-smorest/resources/item.py new file mode 100644 index 00000000..eab1fdaa --- /dev/null +++ b/project/03-items-stores-smorest/resources/item.py @@ -0,0 +1,61 @@ +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from schemas import ItemSchema, ItemUpdateSchema +from db import items + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + try: + return items[item_id] + except KeyError: + abort(404, message="Item not found.") + + def delete(self, item_id): + try: + del items[item_id] + return {"message": "Item deleted."} + except KeyError: + abort(404, message="Item not found.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + try: + item = items[item_id] + + # https://blog.teclado.com/python-dictionary-merge-update-operators/ + item |= item_data + + return item + except KeyError: + abort(404, message="Item not found.") + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return items.values() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + for item in items.values(): + if ( + item_data["name"] == item["name"] + and item_data["store_id"] == item["store_id"] + ): + abort(400, message=f"Item already exists.") + + item_id = uuid.uuid4().hex + item = {**item_data, "id": item_id} + items[item_id] = item + + return item diff --git a/project/03-items-stores-smorest/resources/store.py b/project/03-items-stores-smorest/resources/store.py new file mode 100644 index 00000000..1bfa7cbf --- /dev/null +++ b/project/03-items-stores-smorest/resources/store.py @@ -0,0 +1,47 @@ +import uuid +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from db import stores +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(cls, store_id): + try: + # You presumably would want to include the store's items here too + # More on that when we look at databases + return stores[store_id] + except KeyError: + abort(404, message="Store not found.") + + def delete(cls, store_id): + try: + del stores[store_id] + return {"message": "Store deleted."} + except KeyError: + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(cls): + return stores.values() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(cls, store_data): + for store in stores.values(): + if store_data["name"] == store["name"]: + abort(400, message=f"Store already exists.") + + store_id = uuid.uuid4().hex + store = {**store_data, "id": store_id} + stores[store_id] = store + + return store diff --git a/project/03-items-stores-smorest/schemas.py b/project/03-items-stores-smorest/schemas.py new file mode 100644 index 00000000..0a4ff8d4 --- /dev/null +++ b/project/03-items-stores-smorest/schemas.py @@ -0,0 +1,18 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(Schema): + id = fields.Str(dump_only=True) + name = fields.Str(required=True) diff --git a/project/04-items-stores-smorest-sqlalchemy/.flaskenv b/project/04-items-stores-smorest-sqlalchemy/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/project/04-items-stores-smorest-sqlalchemy/Dockerfile b/project/04-items-stores-smorest-sqlalchemy/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/project/04-items-stores-smorest-sqlalchemy/app.py b/project/04-items-stores-smorest-sqlalchemy/app.py new file mode 100644 index 00000000..f3f475ca --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/app.py @@ -0,0 +1,35 @@ +from flask import Flask +from flask_smorest import Api + +from db import db + +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/project/04-items-stores-smorest-sqlalchemy/db.py b/project/04-items-stores-smorest-sqlalchemy/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/project/04-items-stores-smorest-sqlalchemy/models/__init__.py b/project/04-items-stores-smorest-sqlalchemy/models/__init__.py new file mode 100644 index 00000000..7cab8b1b --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/models/__init__.py @@ -0,0 +1,2 @@ +from models.item import ItemModel +from models.store import StoreModel diff --git a/project/04-items-stores-smorest-sqlalchemy/models/item.py b/project/04-items-stores-smorest-sqlalchemy/models/item.py new file mode 100644 index 00000000..56cb307d --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/models/item.py @@ -0,0 +1,14 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") diff --git a/project/04-items-stores-smorest-sqlalchemy/models/store.py b/project/04-items-stores-smorest-sqlalchemy/models/store.py new file mode 100644 index 00000000..699147f9 --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/project/04-items-stores-smorest-sqlalchemy/requirements.txt b/project/04-items-stores-smorest-sqlalchemy/requirements.txt new file mode 100644 index 00000000..32a85f28 --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/requirements.txt @@ -0,0 +1,6 @@ +flask +flask-smorest +python-dotenv +marshmallow +sqlalchemy +flask-sqlalchemy \ No newline at end of file diff --git a/project/04-items-stores-smorest-sqlalchemy/resources/__init__.py b/project/04-items-stores-smorest-sqlalchemy/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/04-items-stores-smorest-sqlalchemy/resources/item.py b/project/04-items-stores-smorest-sqlalchemy/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/project/04-items-stores-smorest-sqlalchemy/resources/store.py b/project/04-items-stores-smorest-sqlalchemy/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/project/04-items-stores-smorest-sqlalchemy/schemas.py b/project/04-items-stores-smorest-sqlalchemy/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/project/04-items-stores-smorest-sqlalchemy/schemas.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/project/05-add-many-to-many/.flaskenv b/project/05-add-many-to-many/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/project/05-add-many-to-many/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/project/05-add-many-to-many/Dockerfile b/project/05-add-many-to-many/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/project/05-add-many-to-many/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/project/05-add-many-to-many/app.py b/project/05-add-many-to-many/app.py new file mode 100644 index 00000000..8d1cee05 --- /dev/null +++ b/project/05-add-many-to-many/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/project/05-add-many-to-many/conftest.py b/project/05-add-many-to-many/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/project/05-add-many-to-many/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/project/05-add-many-to-many/db.py b/project/05-add-many-to-many/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/project/05-add-many-to-many/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/project/05-add-many-to-many/models/__init__.py b/project/05-add-many-to-many/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/project/05-add-many-to-many/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/project/05-add-many-to-many/models/item.py b/project/05-add-many-to-many/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/project/05-add-many-to-many/models/item.py @@ -0,0 +1,16 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/project/05-add-many-to-many/models/item_tags.py b/project/05-add-many-to-many/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/project/05-add-many-to-many/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/project/05-add-many-to-many/models/store.py b/project/05-add-many-to-many/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/project/05-add-many-to-many/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/project/05-add-many-to-many/models/tag.py b/project/05-add-many-to-many/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/project/05-add-many-to-many/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/project/05-add-many-to-many/requirements.txt b/project/05-add-many-to-many/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/project/05-add-many-to-many/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/project/05-add-many-to-many/resources/__init__.py b/project/05-add-many-to-many/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/05-add-many-to-many/resources/__tests__/conftest.py b/project/05-add-many-to-many/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/project/05-add-many-to-many/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/project/05-add-many-to-many/resources/__tests__/test_item.py b/project/05-add-many-to-many/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/project/05-add-many-to-many/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/project/05-add-many-to-many/resources/__tests__/test_store.py b/project/05-add-many-to-many/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/project/05-add-many-to-many/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/project/05-add-many-to-many/resources/__tests__/test_tag.py b/project/05-add-many-to-many/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/project/05-add-many-to-many/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/project/05-add-many-to-many/resources/item.py b/project/05-add-many-to-many/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/project/05-add-many-to-many/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/project/05-add-many-to-many/resources/store.py b/project/05-add-many-to-many/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/project/05-add-many-to-many/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/project/05-add-many-to-many/resources/tag.py b/project/05-add-many-to-many/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/project/05-add-many-to-many/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/project/05-add-many-to-many/schemas.py b/project/05-add-many-to-many/schemas.py new file mode 100644 index 00000000..99b9c94a --- /dev/null +++ b/project/05-add-many-to-many/schemas.py @@ -0,0 +1,45 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) diff --git a/project/06-add-db-migrations/.flaskenv b/project/06-add-db-migrations/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/project/06-add-db-migrations/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/project/06-add-db-migrations/.python-version b/project/06-add-db-migrations/.python-version new file mode 100644 index 00000000..8d7f852b --- /dev/null +++ b/project/06-add-db-migrations/.python-version @@ -0,0 +1 @@ +3.10.4 diff --git a/project/06-add-db-migrations/Dockerfile b/project/06-add-db-migrations/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/project/06-add-db-migrations/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/project/06-add-db-migrations/app.py b/project/06-add-db-migrations/app.py new file mode 100644 index 00000000..9a2d3940 --- /dev/null +++ b/project/06-add-db-migrations/app.py @@ -0,0 +1,38 @@ +from flask import Flask +from flask_smorest import Api +from flask_migrate import Migrate + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config[ + "OPENAPI_SWAGGER_UI_URL" + ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/project/06-add-db-migrations/conftest.py b/project/06-add-db-migrations/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/project/06-add-db-migrations/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/project/06-add-db-migrations/db.py b/project/06-add-db-migrations/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/project/06-add-db-migrations/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/project/06-add-db-migrations/migrations/README b/project/06-add-db-migrations/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/project/06-add-db-migrations/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/project/06-add-db-migrations/migrations/alembic.ini b/project/06-add-db-migrations/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/project/06-add-db-migrations/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/project/06-add-db-migrations/migrations/env.py b/project/06-add-db-migrations/migrations/env.py new file mode 100644 index 00000000..68feded2 --- /dev/null +++ b/project/06-add-db-migrations/migrations/env.py @@ -0,0 +1,91 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/project/06-add-db-migrations/migrations/script.py.mako b/project/06-add-db-migrations/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/project/06-add-db-migrations/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/project/06-add-db-migrations/migrations/versions/5acd69659946_.py b/project/06-add-db-migrations/migrations/versions/5acd69659946_.py new file mode 100644 index 00000000..91860a6b --- /dev/null +++ b/project/06-add-db-migrations/migrations/versions/5acd69659946_.py @@ -0,0 +1,61 @@ +"""empty message + +Revision ID: 5acd69659946 +Revises: +Create Date: 2022-06-17 14:13:44.923682 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5acd69659946' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/project/06-add-db-migrations/migrations/versions/a40bdfbd7a9d_.py b/project/06-add-db-migrations/migrations/versions/a40bdfbd7a9d_.py new file mode 100644 index 00000000..d16b8fba --- /dev/null +++ b/project/06-add-db-migrations/migrations/versions/a40bdfbd7a9d_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: a40bdfbd7a9d +Revises: 5acd69659946 +Create Date: 2022-06-17 14:19:34.934726 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a40bdfbd7a9d' +down_revision = '5acd69659946' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/project/06-add-db-migrations/models/__init__.py b/project/06-add-db-migrations/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/project/06-add-db-migrations/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/project/06-add-db-migrations/models/item.py b/project/06-add-db-migrations/models/item.py new file mode 100644 index 00000000..f1cffd20 --- /dev/null +++ b/project/06-add-db-migrations/models/item.py @@ -0,0 +1,17 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") diff --git a/project/06-add-db-migrations/models/item_tags.py b/project/06-add-db-migrations/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/project/06-add-db-migrations/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/project/06-add-db-migrations/models/store.py b/project/06-add-db-migrations/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/project/06-add-db-migrations/models/store.py @@ -0,0 +1,11 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/project/06-add-db-migrations/models/tag.py b/project/06-add-db-migrations/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/project/06-add-db-migrations/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") diff --git a/project/06-add-db-migrations/requirements.txt b/project/06-add-db-migrations/requirements.txt new file mode 100644 index 00000000..24e73218 --- /dev/null +++ b/project/06-add-db-migrations/requirements.txt @@ -0,0 +1,8 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +Flask-Migrate +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/project/06-add-db-migrations/resources/__init__.py b/project/06-add-db-migrations/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/06-add-db-migrations/resources/__tests__/conftest.py b/project/06-add-db-migrations/resources/__tests__/conftest.py new file mode 100644 index 00000000..59b05baf --- /dev/null +++ b/project/06-add-db-migrations/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/project/06-add-db-migrations/resources/__tests__/test_item.py b/project/06-add-db-migrations/resources/__tests__/test_item.py new file mode 100644 index 00000000..78267a8a --- /dev/null +++ b/project/06-add-db-migrations/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/item/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/item", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/item", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + response = client.get( + f"/item/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/item/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/project/06-add-db-migrations/resources/__tests__/test_store.py b/project/06-add-db-migrations/resources/__tests__/test_store.py new file mode 100644 index 00000000..8f2cd74e --- /dev/null +++ b/project/06-add-db-migrations/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/store/{created_store_id}/tag", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/store/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/store/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/store", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + "/item", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/store", + json={"name": "Test Store"}, + ) + client.post( + f"/store/{resp.json['id']}/tag", + json={"name": "Test Tag"}, + ) + response = client.get( + "/store", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/store", + json={"name": "Test Store"}, + ) + + response = client.post( + "/store", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/project/06-add-db-migrations/resources/__tests__/test_tag.py b/project/06-add-db-migrations/resources/__tests__/test_tag.py new file mode 100644 index 00000000..620e669c --- /dev/null +++ b/project/06-add-db-migrations/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/item/{created_item_id}/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") + + response = client.get( + f"/tag/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tag/{created_tag_id}") + + response = client.get( + f"/tag/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tag/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tag/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/store/{created_store_id}/tag", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/store/1/tag", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/project/06-add-db-migrations/resources/item.py b/project/06-add-db-migrations/resources/item.py new file mode 100644 index 00000000..c8a96721 --- /dev/null +++ b/project/06-add-db-migrations/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item diff --git a/project/06-add-db-migrations/resources/store.py b/project/06-add-db-migrations/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/project/06-add-db-migrations/resources/store.py @@ -0,0 +1,48 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store diff --git a/project/06-add-db-migrations/resources/tag.py b/project/06-add-db-migrations/resources/tag.py new file mode 100644 index 00000000..d42ee548 --- /dev/null +++ b/project/06-add-db-migrations/resources/tag.py @@ -0,0 +1,100 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).first(): + abort(400, message="A tag with that name already exists in that store.") + + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e), + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."}, + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) diff --git a/project/06-add-db-migrations/schemas.py b/project/06-add-db-migrations/schemas.py new file mode 100644 index 00000000..99b9c94a --- /dev/null +++ b/project/06-add-db-migrations/schemas.py @@ -0,0 +1,45 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) diff --git a/project/using-flask-restful/.flaskenv b/project/using-flask-restful/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/project/using-flask-restful/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/section11/Flask-JWT-Extended.postman_collection.json b/project/using-flask-restful/Flask-JWT-Extended.postman_collection.json similarity index 98% rename from section11/Flask-JWT-Extended.postman_collection.json rename to project/using-flask-restful/Flask-JWT-Extended.postman_collection.json index fdb5e450..4f5fd5fc 100644 --- a/section11/Flask-JWT-Extended.postman_collection.json +++ b/project/using-flask-restful/Flask-JWT-Extended.postman_collection.json @@ -364,7 +364,7 @@ "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" }, "url": { - "raw": "{{local_flask}}/items", + "raw": "{{local_flask}}/item", "host": [ "{{local_flask}}" ], @@ -385,7 +385,7 @@ "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" }, "url": { - "raw": "{{local_flask}}/items", + "raw": "{{local_flask}}/item", "host": [ "{{local_flask}}" ], @@ -466,7 +466,7 @@ "header": [], "body": {}, "url": { - "raw": "{{server_address}}/stores", + "raw": "{{server_address}}/store", "host": [ "{{server_address}}" ], diff --git a/project/using-flask-restful/Stores_REST_API_2022-01-14.json b/project/using-flask-restful/Stores_REST_API_2022-01-14.json new file mode 100644 index 00000000..400012df --- /dev/null +++ b/project/using-flask-restful/Stores_REST_API_2022-01-14.json @@ -0,0 +1 @@ +{"_type":"export","__export_format":4,"__export_date":"2022-01-14T11:50:51.742Z","__export_source":"insomnia.desktop.app:v2021.7.2","resources":[{"_id":"req_efcadee1c4fc48f099644e23398a5d29","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159057139,"created":1642157007062,"url":"{{url}}/register","name":"/register","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_64c3752c7f694f0aa830bacba3b35aea"},{"name":"Authorization","value":"JWT","id":"pair_d143b36c4aa74f9681dc1590970da3b7"}],"authentication":{},"metaSortKey":-1642157660252,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_fd1f956aae16470fafdc3d611d34a80a","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157670592,"created":1642157670592,"name":"Authentication","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157670592,"_type":"request_group"},{"_id":"wrk_c441bf446d174d1bb2f01c7ad66c695b","parentId":null,"modified":1642157007080,"created":1642149963161,"name":"Stores REST API","description":"","scope":"collection","_type":"workspace"},{"_id":"req_16415c75944342dab73119513e7bd20b","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159087108,"created":1642157007061,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_a8caec1064eb43b7ac5c8c9294be13a3"}],"authentication":{},"metaSortKey":-1642157660202,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b08c51961bea4413a31fba1af93b3759","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642159093249,"created":1642157007054,"url":"{{url}}/item/my_item","name":"/item/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": 3\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"},{"id":"pair_4926e48dcb594eaa9c79a78b801b708f","name":"Authorization","value":"Bearer {% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'always', 60 %}","description":""}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_5bf669c32c3145a3a80dee2d6523f9ac","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157649712,"created":1642157649712,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157649712,"_type":"request_group"},{"_id":"req_e553e5091f714becb81e1b27bfc8f34b","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642160992156,"created":1642157007053,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"Bearer {{access_token}}","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1f64b6c8fc8642aa9c267c8d49c72435","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157976127,"created":1642157007052,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_62498017fcb34ba0a3a19b4e0f2d4499","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_69d7ed86b4dc4b72a72778f97a77e05c","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157658823,"created":1642157007048,"url":"{{url}}/item","name":"/item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007178.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_054be716de114cc49d6e49d04a5a901b","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642159101503,"created":1642157684047,"url":"{{url}}/tag/my_tag","name":"/tag/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"item_id\": {% response 'body', 'req_e553e5091f714becb81e1b27bfc8f34b', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_adac84f9834d4e948ceb02807787c935","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157684028,"created":1642157684028,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157641282.5,"_type":"request_group"},{"_id":"req_a5b2631adf9e4ef894a8a1b9d2c77aa8","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158069229,"created":1642157684046,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"JWT ","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5dbef7a9b30d4ee68c148c4af447c241","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158073367,"created":1642157684041,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7e8e3838e1cb41b485e091bf667b0764","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157645538,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320140.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157632853,"created":1642157632853,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157632853,"_type":"request_group"},{"_id":"req_f99ce0192797434f99657221acc45fe3","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157643850,"created":1642157007059,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320090.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_82705a91a36849b09f1347d135816761","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157642682,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320040.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_94efb5c0488d43d8be95fd82b33afb97","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157639015,"created":1642157007060,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157319990.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9bc4db6f4d02466aba86edef29722854","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157781373,"created":1642157007070,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_8c8f8b7b9ddd4c3ca7fb6df5418b7f2e"}],"authentication":{},"metaSortKey":-1642157007070,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_05fce0fdc405492d8a7f87842e6d4e13","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157017423,"created":1642157007072,"name":"User create store and item","description":"Check user can register.\nCheck user can create store.\nCheck user can create item in store.","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157007128,"_type":"request_group"},{"_id":"req_2ce4ecd840094ac1a164d7a0bfbb6d83","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157887543,"created":1642157007069,"url":"{{url}}/store/test_store","name":"/store/test_store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007069,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8c68f13e77a74937b62cde1ae24bed61","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157923539,"created":1642157007068,"url":"{{url}}/item/test_item","name":"/item/test_item in test_store","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": {% response 'body', 'req_2ce4ecd840094ac1a164d7a0bfbb6d83', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_d0c5451b8b044511b76436c627ffc4bb"},{"id":"pair_eb8ca7f686334ae5a48ad48412436ad9","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007068,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_297f6099ee274c1f8ccceb3bc29ad582","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007065,"created":1642157007065,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007065,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_84a807bef7cd4a66bc81f5401f0639cd","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157931016,"created":1642157007064,"url":"{{url}}/item/test_item","name":"/item/my_item copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_daca8133eb94474ca84748a0e4c8bcaf","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007064,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_10076b1332f2458e897d7b5200c7e5de","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007063,"created":1642157007063,"url":"{{url}}/store/test_store","name":"/store/ copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007063,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642160933906,"created":1642149963165,"name":"Base Environment","data":{"url":"http://127.0.0.1:5000","access_token":"{% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'never', 60 %}"},"dataPropertyOrder":{"&":["url","access_token"]},"color":null,"isPrivate":false,"metaSortKey":1642149963165,"_type":"environment"},{"_id":"jar_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963166,"created":1642149963166,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_f25b8aff2219447aa56189a385b1663c","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963162,"created":1642149963162,"fileName":"Stores REST API","contents":"","contentType":"yaml","_type":"api_spec"}]} \ No newline at end of file diff --git a/project/using-flask-restful/app.py b/project/using-flask-restful/app.py new file mode 100644 index 00000000..a2b33ef8 --- /dev/null +++ b/project/using-flask-restful/app.py @@ -0,0 +1,114 @@ +from flask import Flask, jsonify +from flask_restful import Api +from flask_jwt_extended import JWTManager + +from db import db +from blocklist import BLOCKLIST +from resources.user import UserRegister, UserLogin, User, TokenRefresh, UserLogout +from resources.item import Item, ItemList +from resources.store import Store, StoreList +from resources.tag import Tag + +app = Flask(__name__) +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["PROPAGATE_EXCEPTIONS"] = True +db.init_app(app) +api = Api(app) + +""" +JWT related configuration. The following functions includes: +1) add claims to each jwt +2) customize the token expired error message +""" +app.config["JWT_SECRET_KEY"] = "jose" +jwt = JWTManager(app) + +""" +`claims` are data we choose to attach to each jwt payload +and for each jwt protected endpoint, we can retrieve these claims via `get_jwt_claims()` +one possible use case for claims are access level control, which is shown below +""" + + +@jwt.additional_claims_loader +def add_claims_to_jwt(identity): + # TODO: Read from a config file instead of hard-coding + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + +@jwt.token_in_blocklist_loader +def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + +@jwt.expired_token_loader +def expired_token_callback(jwt_header, jwt_payload): + return jsonify({"message": "The token has expired.", "error": "token_expired"}), 401 + + +@jwt.invalid_token_loader +def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + +@jwt.unauthorized_loader +def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + +@jwt.needs_fresh_token_loader +def token_not_fresh_callback(): + return ( + jsonify( + {"description": "The token is not fresh.", "error": "fresh_token_required"} + ), + 401, + ) + + +@jwt.revoked_token_loader +def revoked_token_callback(): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + +# JWT configuration ends + + +@app.before_first_request +def create_tables(): + import models # noqa: F401 + + db.create_all() + + +api.add_resource(UserRegister, "/register") +api.add_resource(UserLogin, "/login") +api.add_resource(UserLogout, "/logout") +api.add_resource(User, "/user/") +api.add_resource(TokenRefresh, "/refresh") +api.add_resource(Store, "/store/") +api.add_resource(StoreList, "/store") +api.add_resource(Item, "/item/") +api.add_resource(ItemList, "/item") +api.add_resource(Tag, "/tag/") diff --git a/section11/blacklist.py b/project/using-flask-restful/blocklist.py similarity index 54% rename from section11/blacklist.py rename to project/using-flask-restful/blocklist.py index 9ca19645..ef2383ba 100644 --- a/section11/blacklist.py +++ b/project/using-flask-restful/blocklist.py @@ -1,9 +1,9 @@ """ blacklist.py -This file just contains the blacklist of the JWT tokens–it will be imported by +This file just contains the blacklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blacklist when the user logs out. """ -BLACKLIST = set() +BLOCKLIST = set() diff --git a/project/using-flask-restful/db.py b/project/using-flask-restful/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/project/using-flask-restful/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/project/using-flask-restful/models/__init__.py b/project/using-flask-restful/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/project/using-flask-restful/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/project/using-flask-restful/models/item.py b/project/using-flask-restful/models/item.py new file mode 100644 index 00000000..9995b296 --- /dev/null +++ b/project/using-flask-restful/models/item.py @@ -0,0 +1,41 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") + + def json(self): + return { + "id": self.id, + "name": self.name, + "price": self.price, + "store_id": self.store_id, + "tags": [tag.json() for tag in self.tags], + } + + @classmethod + def find_by_name(cls, name): + return cls.query.filter_by(name=name).first() + + @classmethod + def find_all(cls): + return cls.query.all() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/project/using-flask-restful/models/item_tags.py b/project/using-flask-restful/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/project/using-flask-restful/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/section11/models/store.py b/project/using-flask-restful/models/store.py similarity index 62% rename from section11/models/store.py rename to project/using-flask-restful/models/store.py index e329cd59..e3dad08d 100644 --- a/section11/models/store.py +++ b/project/using-flask-restful/models/store.py @@ -2,21 +2,18 @@ class StoreModel(db.Model): - __tablename__ = 'stores' + __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(80)) + name = db.Column(db.String(80), unique=True, nullable=False) - items = db.relationship('ItemModel', lazy='dynamic') - - def __init__(self, name): - self.name = name + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") def json(self): return { - 'id': self.id, - 'name': self.name, - 'items': [item.json() for item in self.items.all()] + "id": self.id, + "name": self.name, + "items": [item.json() for item in self.items.all()], } @classmethod diff --git a/project/using-flask-restful/models/tag.py b/project/using-flask-restful/models/tag.py new file mode 100644 index 00000000..23332527 --- /dev/null +++ b/project/using-flask-restful/models/tag.py @@ -0,0 +1,32 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") + + def json(self): + return { + "id": self.id, + "name": self.name, + "items": [item.name for item in self.items], + } + + @classmethod + def find_by_name(cls, name): + return cls.query.filter_by(name=name).first() + + @classmethod + def find_all(cls): + return cls.query.all() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/section11/models/user.py b/project/using-flask-restful/models/user.py similarity index 76% rename from section11/models/user.py rename to project/using-flask-restful/models/user.py index 8621cedf..585afce9 100644 --- a/section11/models/user.py +++ b/project/using-flask-restful/models/user.py @@ -5,12 +5,8 @@ class UserModel(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(80)) - password = db.Column(db.String(80)) - - def __init__(self, username, password): - self.username = username - self.password = password + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) def json(self): return { diff --git a/project/using-flask-restful/requirements.txt b/project/using-flask-restful/requirements.txt new file mode 100644 index 00000000..6263febe --- /dev/null +++ b/project/using-flask-restful/requirements.txt @@ -0,0 +1,5 @@ +Flask-JWT-Extended +Flask-RESTful +Flask-SQLAlchemy +passlib +python-dotenv diff --git a/project/using-flask-restful/resources/__init__.py b/project/using-flask-restful/resources/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/project/using-flask-restful/resources/__init__.py @@ -0,0 +1 @@ + diff --git a/project/using-flask-restful/resources/item.py b/project/using-flask-restful/resources/item.py new file mode 100644 index 00000000..241d1333 --- /dev/null +++ b/project/using-flask-restful/resources/item.py @@ -0,0 +1,78 @@ +from flask_restful import Resource, reqparse +from flask_jwt_extended import get_jwt_identity, jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError +from models import ItemModel + + +class Item(Resource): + parser = reqparse.RequestParser() + parser.add_argument( + "price", type=float, required=True, help="This field cannot be left blank!" + ) + parser.add_argument( + "store_id", type=int, required=True, help="Every item needs a store_id." + ) + + @jwt_required() + def get(self, name): + item = ItemModel.find_by_name(name) + if item: + return item.json() + return {"message": "Item not found"}, 404 + + @jwt_required(fresh=True) + def post(self, name): + if ItemModel.find_by_name(name): + return { + "message": "An item with name '{}' already exists.".format(name) + }, 400 + + data = self.parser.parse_args() + + item = ItemModel(name=name, **data) + + try: + item.save_to_db() + except SQLAlchemyError: + return {"message": "An error occurred while inserting the item."}, 500 + + return item.json(), 201 + + @jwt_required() + def delete(self, name): + jwt = get_jwt() + if not jwt["is_admin"]: + return {"message": "Admin privilege required."}, 401 + + item = ItemModel.find_by_name(name) + if item: + item.delete_from_db() + return {"message": "Item deleted."} + return {"message": "Item not found."}, 404 + + def put(self, name): + data = self.parser.parse_args() + + item = ItemModel.find_by_name(name) + + if item: + item.price = data["price"] + else: + item = ItemModel(name, **data) + + item.save_to_db() + + return item.json() + + +class ItemList(Resource): + @jwt_required(optional=True) + def get(self): + user_id = get_jwt_identity() + items = [item.json() for item in ItemModel.find_all()] + if user_id: + return {"items": items}, 200 + return { + "items": [item["name"] for item in items], + "message": "More data available if you log in.", + }, 200 diff --git a/section11/resources/store.py b/project/using-flask-restful/resources/store.py similarity index 57% rename from section11/resources/store.py rename to project/using-flask-restful/resources/store.py index f8ef75b6..2a5e9944 100644 --- a/section11/resources/store.py +++ b/project/using-flask-restful/resources/store.py @@ -1,5 +1,6 @@ from flask_restful import Resource -from models.store import StoreModel +from sqlalchemy.exc import SQLAlchemyError +from models import StoreModel class Store(Resource): @@ -8,17 +9,19 @@ def get(cls, name): store = StoreModel.find_by_name(name) if store: return store.json() - return {'message': 'Store not found'}, 404 + return {"message": "Store not found"}, 404 @classmethod def post(cls, name): if StoreModel.find_by_name(name): - return {'message': "A store with name '{}' already exists.".format(name)}, 400 + return { + "message": "A store with name '{}' already exists.".format(name) + }, 400 - store = StoreModel(name) + store = StoreModel(name=name) try: store.save_to_db() - except: + except SQLAlchemyError: return {"message": "An error occurred creating the store."}, 500 return store.json(), 201 @@ -28,11 +31,11 @@ def delete(cls, name): store = StoreModel.find_by_name(name) if store: store.delete_from_db() - - return {'message': 'Store deleted'} + return {"message": "Store deleted"}, 200 + return {"message": "Store not found"}, 404 class StoreList(Resource): @classmethod def get(cls): - return {'stores': [store.json() for store in StoreModel.find_all()]} + return {"stores": [store.json() for store in StoreModel.find_all()]} diff --git a/project/using-flask-restful/resources/tag.py b/project/using-flask-restful/resources/tag.py new file mode 100644 index 00000000..867eecaa --- /dev/null +++ b/project/using-flask-restful/resources/tag.py @@ -0,0 +1,65 @@ +from flask_restful import Resource, reqparse +from werkzeug.exceptions import BadRequest +from sqlalchemy.exc import SQLAlchemyError +from models import TagModel +from models import ItemModel + + +class Tag(Resource): + parser = reqparse.RequestParser() + parser.add_argument( + "item_id", + type=int, + required=True, + help="To create or add a tag to an item, please provide the item_id.", + ) + + def get(self, name): + tag = TagModel.find_by_name(name) + if tag: + return tag.json() + return {"message": "Tag not found"}, 404 + + def post(self, name): + tag = TagModel.find_by_name(name) + if not tag: + tag = TagModel(name=name) + + # Add the item to the tag + data = self.parser.parse_args() + item = ItemModel.query.get(data["item_id"]) + + if not item: + return {"message": "An item with this item_id doesn't exist."}, 400 + + tag.items.append(item) + + try: + tag.save_to_db() + except SQLAlchemyError: + return {"message": "An error occurred while inserting the tag."}, 500 + + return tag.json(), 201 + + def delete(self, name): + tag = TagModel.find_by_name(name) + try: + data = self.parser.parse_args() + if "item_id" in data: + item = ItemModel.query.get(data["item_id"]) + tag.items.remove(item) + return { + "message": "Item removed from tag", + "item": item.json(), + "tag": tag.json(), + } + except BadRequest: + # Assume no item_id was passed. Instead delete entire tag. + # First check tag has no items + if not tag.items: + tag.delete_from_db() + return {"message": "Tag deleted."} + return { + "message": "Could not delete tag. Make sure tag is not associated with any items, then try again." + } + return {"message": "Tag not found."}, 404 diff --git a/project/using-flask-restful/resources/user.py b/project/using-flask-restful/resources/user.py new file mode 100644 index 00000000..7cd4f234 --- /dev/null +++ b/project/using-flask-restful/resources/user.py @@ -0,0 +1,88 @@ +from flask_restful import Resource, reqparse +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from models import UserModel +from blocklist import BLOCKLIST + +_user_parser = reqparse.RequestParser() +_user_parser.add_argument( + "username", type=str, required=True, help="This field cannot be blank." +) +_user_parser.add_argument( + "password", type=str, required=True, help="This field cannot be blank." +) + + +class UserRegister(Resource): + def post(self): + data = _user_parser.parse_args() + + if UserModel.find_by_username(data["username"]): + return {"message": "A user with that username already exists"}, 400 + + user = UserModel( + username=data["username"], password=pbkdf2_sha256.hash(data["password"]) + ) + user.save_to_db() + + return {"message": "User created successfully."}, 201 + + +class UserLogin(Resource): + def post(self): + data = _user_parser.parse_args() + + user = UserModel.find_by_username(data["username"]) + + if user and pbkdf2_sha256.verify(data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + return {"message": "Invalid Credentials!"}, 401 + + +class UserLogout(Resource): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +class User(Resource): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @classmethod + def get(cls, user_id): + user = UserModel.find_by_id(user_id) + if not user: + return {"message": "User Not Found"}, 404 + return user.json(), 200 + + def delete(self, user_id): + user = UserModel.find_by_id(user_id) + if not user: + return {"message": "User Not Found"}, 404 + user.delete_from_db() + return {"message": "User deleted."}, 200 + + +class TokenRefresh(Resource): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token}, 200 diff --git a/project/using-flask-restx/.flaskenv b/project/using-flask-restx/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/project/using-flask-restx/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/project/using-flask-restx/Flask-JWT-Extended.postman_collection.json b/project/using-flask-restx/Flask-JWT-Extended.postman_collection.json new file mode 100644 index 00000000..4f5fd5fc --- /dev/null +++ b/project/using-flask-restx/Flask-JWT-Extended.postman_collection.json @@ -0,0 +1,483 @@ +{ + "info": { + "_postman_id": "74a1833f-bc4e-4e85-a525-72d268ab9999", + "name": "Flask-JWT-Extended", + "description": "This collection contains requests associated witht the Flask-JWT-Extended section of the REST API course.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "users", + "description": "", + "item": [ + { + "name": "register a new user", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" + }, + "url": { + "raw": "{{server_address}}/register", + "host": [ + "{{server_address}}" + ], + "path": [ + "register" + ] + } + }, + "response": [] + }, + { + "name": "get user by id", + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" + }, + "url": { + "raw": "{{server_address}}/user/1", + "host": [ + "{{server_address}}" + ], + "path": [ + "user", + "1" + ] + } + }, + "response": [] + }, + { + "name": "delete user by id", + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" + }, + "url": { + "raw": "{{server_address}}/user/2", + "host": [ + "{{server_address}}" + ], + "path": [ + "user", + "2" + ] + } + }, + "response": [] + }, + { + "name": "login", + "event": [ + { + "listen": "test", + "script": { + "id": "8c0c0ed6-c206-4c88-9349-429e024e312b", + "type": "text/javascript", + "exec": [ + "var jsonData = pm.response.json();", + "pm.test(\"access_token not empty\", function () {", + " pm.expect(jsonData.access_token).not.eql(undefined);", + "});", + "", + "pm.test(\"refresh token not empty\", function () {", + " pm.expect(jsonData.refresh_token).not.eql(undefined);", + "});", + "// set access token as environement variable", + "if (jsonData.access_token !== undefined) {", + " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", + "} else {", + " postman.setEnvironmentVariable(\"access_token\", null);", + "}", + "// set refresh token as environement variable", + "if (jsonData.refresh_token !== undefined) {", + " postman.setEnvironmentVariable(\"refresh_token\", jsonData.refresh_token);", + "} else {", + " postman.setEnvironmentVariable(\"refresh_token\", null);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"username\" : \"jose\",\n \"password\" : \"1234\"\n}" + }, + "url": { + "raw": "{{server_address}}/login", + "host": [ + "{{server_address}}" + ], + "path": [ + "login" + ] + } + }, + "response": [] + }, + { + "name": "logout", + "event": [ + { + "listen": "test", + "script": { + "id": "dc763e9b-e6c7-4ff3-9766-637976a5c64b", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{server_address}}/logout", + "host": [ + "{{server_address}}" + ], + "path": [ + "logout" + ] + } + }, + "response": [] + }, + { + "name": "refresh token", + "event": [ + { + "listen": "test", + "script": { + "id": "ad818ea6-8f79-436e-b756-ad878666ae9e", + "type": "text/javascript", + "exec": [ + "var jsonData = pm.response.json();", + "pm.test(\"access_token not empty\", function () {", + " pm.expect(jsonData.access_token).not.eql(undefined);", + "});", + "// set access token as environement variable", + "if (jsonData.access_token !== undefined) {", + " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", + "} else {", + " postman.setEnvironmentVariable(\"access_token\", null);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{refresh_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{local_flask}}/refresh", + "host": [ + "{{local_flask}}" + ], + "path": [ + "refresh" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "items", + "description": "", + "item": [ + { + "name": "get item/name", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": {}, + "url": { + "raw": "{{local_flask}}/item/chair", + "host": [ + "{{local_flask}}" + ], + "path": [ + "item", + "chair" + ] + } + }, + "response": [] + }, + { + "name": "post item/name", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" + }, + "url": { + "raw": "{{local_flask}}/item/chair", + "host": [ + "{{local_flask}}" + ], + "path": [ + "item", + "chair" + ] + } + }, + "response": [] + }, + { + "name": "put item/name", + "request": { + "method": "PUT", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" + }, + "url": { + "raw": "{{local_flask}}/item/chair", + "host": [ + "{{local_flask}}" + ], + "path": [ + "item", + "chair" + ] + } + }, + "response": [] + }, + { + "name": "delete item by name", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{local_flask}}/item/chair", + "host": [ + "{{local_flask}}" + ], + "path": [ + "item", + "chair" + ] + } + }, + "response": [] + }, + { + "name": "get all items", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" + }, + "url": { + "raw": "{{local_flask}}/item", + "host": [ + "{{local_flask}}" + ], + "path": [ + "items" + ] + } + }, + "response": [] + }, + { + "name": "get all items without JWT", + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" + }, + "url": { + "raw": "{{local_flask}}/item", + "host": [ + "{{local_flask}}" + ], + "path": [ + "items" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "stores", + "description": "", + "item": [ + { + "name": "create a new store", + "request": { + "method": "POST", + "header": [], + "body": {}, + "url": { + "raw": "{{server_address}}/store/My Wonderful Store", + "host": [ + "{{server_address}}" + ], + "path": [ + "store", + "My Wonderful Store" + ] + } + }, + "response": [] + }, + { + "name": "get store by name", + "request": { + "method": "GET", + "header": [], + "body": {}, + "url": { + "raw": "{{server_address}}/store/My Wonderful Store", + "host": [ + "{{server_address}}" + ], + "path": [ + "store", + "My Wonderful Store" + ] + } + }, + "response": [] + }, + { + "name": "delete a new store by name", + "request": { + "method": "DELETE", + "header": [], + "body": {}, + "url": { + "raw": "{{server_address}}/store/My Wonderful Store", + "host": [ + "{{server_address}}" + ], + "path": [ + "store", + "My Wonderful Store" + ] + } + }, + "response": [] + }, + { + "name": "get all stores", + "request": { + "method": "GET", + "header": [], + "body": {}, + "url": { + "raw": "{{server_address}}/store", + "host": [ + "{{server_address}}" + ], + "path": [ + "stores" + ] + } + }, + "response": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/project/using-flask-restx/Stores_REST_API_2022-01-14.json b/project/using-flask-restx/Stores_REST_API_2022-01-14.json new file mode 100644 index 00000000..400012df --- /dev/null +++ b/project/using-flask-restx/Stores_REST_API_2022-01-14.json @@ -0,0 +1 @@ +{"_type":"export","__export_format":4,"__export_date":"2022-01-14T11:50:51.742Z","__export_source":"insomnia.desktop.app:v2021.7.2","resources":[{"_id":"req_efcadee1c4fc48f099644e23398a5d29","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159057139,"created":1642157007062,"url":"{{url}}/register","name":"/register","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_64c3752c7f694f0aa830bacba3b35aea"},{"name":"Authorization","value":"JWT","id":"pair_d143b36c4aa74f9681dc1590970da3b7"}],"authentication":{},"metaSortKey":-1642157660252,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_fd1f956aae16470fafdc3d611d34a80a","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157670592,"created":1642157670592,"name":"Authentication","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157670592,"_type":"request_group"},{"_id":"wrk_c441bf446d174d1bb2f01c7ad66c695b","parentId":null,"modified":1642157007080,"created":1642149963161,"name":"Stores REST API","description":"","scope":"collection","_type":"workspace"},{"_id":"req_16415c75944342dab73119513e7bd20b","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159087108,"created":1642157007061,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_a8caec1064eb43b7ac5c8c9294be13a3"}],"authentication":{},"metaSortKey":-1642157660202,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b08c51961bea4413a31fba1af93b3759","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642159093249,"created":1642157007054,"url":"{{url}}/item/my_item","name":"/item/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": 3\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"},{"id":"pair_4926e48dcb594eaa9c79a78b801b708f","name":"Authorization","value":"Bearer {% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'always', 60 %}","description":""}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_5bf669c32c3145a3a80dee2d6523f9ac","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157649712,"created":1642157649712,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157649712,"_type":"request_group"},{"_id":"req_e553e5091f714becb81e1b27bfc8f34b","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642160992156,"created":1642157007053,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"Bearer {{access_token}}","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1f64b6c8fc8642aa9c267c8d49c72435","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157976127,"created":1642157007052,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_62498017fcb34ba0a3a19b4e0f2d4499","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_69d7ed86b4dc4b72a72778f97a77e05c","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157658823,"created":1642157007048,"url":"{{url}}/item","name":"/item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007178.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_054be716de114cc49d6e49d04a5a901b","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642159101503,"created":1642157684047,"url":"{{url}}/tag/my_tag","name":"/tag/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"item_id\": {% response 'body', 'req_e553e5091f714becb81e1b27bfc8f34b', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_adac84f9834d4e948ceb02807787c935","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157684028,"created":1642157684028,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157641282.5,"_type":"request_group"},{"_id":"req_a5b2631adf9e4ef894a8a1b9d2c77aa8","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158069229,"created":1642157684046,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"JWT ","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5dbef7a9b30d4ee68c148c4af447c241","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158073367,"created":1642157684041,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7e8e3838e1cb41b485e091bf667b0764","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157645538,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320140.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157632853,"created":1642157632853,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157632853,"_type":"request_group"},{"_id":"req_f99ce0192797434f99657221acc45fe3","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157643850,"created":1642157007059,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320090.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_82705a91a36849b09f1347d135816761","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157642682,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320040.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_94efb5c0488d43d8be95fd82b33afb97","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157639015,"created":1642157007060,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157319990.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9bc4db6f4d02466aba86edef29722854","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157781373,"created":1642157007070,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_8c8f8b7b9ddd4c3ca7fb6df5418b7f2e"}],"authentication":{},"metaSortKey":-1642157007070,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_05fce0fdc405492d8a7f87842e6d4e13","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157017423,"created":1642157007072,"name":"User create store and item","description":"Check user can register.\nCheck user can create store.\nCheck user can create item in store.","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157007128,"_type":"request_group"},{"_id":"req_2ce4ecd840094ac1a164d7a0bfbb6d83","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157887543,"created":1642157007069,"url":"{{url}}/store/test_store","name":"/store/test_store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007069,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8c68f13e77a74937b62cde1ae24bed61","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157923539,"created":1642157007068,"url":"{{url}}/item/test_item","name":"/item/test_item in test_store","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": {% response 'body', 'req_2ce4ecd840094ac1a164d7a0bfbb6d83', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_d0c5451b8b044511b76436c627ffc4bb"},{"id":"pair_eb8ca7f686334ae5a48ad48412436ad9","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007068,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_297f6099ee274c1f8ccceb3bc29ad582","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007065,"created":1642157007065,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007065,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_84a807bef7cd4a66bc81f5401f0639cd","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157931016,"created":1642157007064,"url":"{{url}}/item/test_item","name":"/item/my_item copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_daca8133eb94474ca84748a0e4c8bcaf","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007064,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_10076b1332f2458e897d7b5200c7e5de","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007063,"created":1642157007063,"url":"{{url}}/store/test_store","name":"/store/ copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007063,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642160933906,"created":1642149963165,"name":"Base Environment","data":{"url":"http://127.0.0.1:5000","access_token":"{% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'never', 60 %}"},"dataPropertyOrder":{"&":["url","access_token"]},"color":null,"isPrivate":false,"metaSortKey":1642149963165,"_type":"environment"},{"_id":"jar_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963166,"created":1642149963166,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_f25b8aff2219447aa56189a385b1663c","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963162,"created":1642149963162,"fileName":"Stores REST API","contents":"","contentType":"yaml","_type":"api_spec"}]} \ No newline at end of file diff --git a/project/using-flask-restx/app.py b/project/using-flask-restx/app.py new file mode 100644 index 00000000..68736440 --- /dev/null +++ b/project/using-flask-restx/app.py @@ -0,0 +1,108 @@ +from flask import Flask, jsonify +from flask_restx import Api +from flask_jwt_extended import JWTManager + +from db import db +from blocklist import BLOCKLIST +from resources.user import api as user_namespace +from resources.item import api as item_namespace +from resources.store import api as store_namespace +from resources.tag import api as tag_namespace + +app = Flask(__name__) +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["PROPAGATE_EXCEPTIONS"] = True +db.init_app(app) +api = Api(app) + +""" +JWT related configuration. The following functions includes: +1) add claims to each jwt +2) customize the token expired error message +""" +app.config["JWT_SECRET_KEY"] = "jose" +jwt = JWTManager(app) + +""" +`claims` are data we choose to attach to each jwt payload +and for each jwt protected endpoint, we can retrieve these claims via `get_jwt_claims()` +one possible use case for claims are access level control, which is shown below +""" + + +@jwt.additional_claims_loader +def add_claims_to_jwt(identity): + # TODO: Read from a config file instead of hard-coding + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + +@jwt.token_in_blocklist_loader +def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + +@jwt.expired_token_loader +def expired_token_callback(jwt_header, jwt_payload): + return jsonify({"message": "The token has expired.", "error": "token_expired"}), 401 + + +@jwt.invalid_token_loader +def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + +@jwt.unauthorized_loader +def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + +@jwt.needs_fresh_token_loader +def token_not_fresh_callback(): + return ( + jsonify( + {"description": "The token is not fresh.", "error": "fresh_token_required"} + ), + 401, + ) + + +@jwt.revoked_token_loader +def revoked_token_callback(): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + +# JWT configuration ends + + +@app.before_first_request +def create_tables(): + import models # noqa: F401 + + db.create_all() + + +api.add_namespace(user_namespace, path="/") +api.add_namespace(item_namespace, path="/item") +api.add_namespace(store_namespace, path="/store") +api.add_namespace(tag_namespace, path="/tag") diff --git a/project/using-flask-restx/blocklist.py b/project/using-flask-restx/blocklist.py new file mode 100644 index 00000000..ef2383ba --- /dev/null +++ b/project/using-flask-restx/blocklist.py @@ -0,0 +1,9 @@ +""" +blacklist.py + +This file just contains the blacklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blacklist when the +user logs out. +""" + +BLOCKLIST = set() diff --git a/project/using-flask-restx/db.py b/project/using-flask-restx/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/project/using-flask-restx/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/project/using-flask-restx/models/__init__.py b/project/using-flask-restx/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/project/using-flask-restx/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/project/using-flask-restx/models/item.py b/project/using-flask-restx/models/item.py new file mode 100644 index 00000000..9995b296 --- /dev/null +++ b/project/using-flask-restx/models/item.py @@ -0,0 +1,41 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") + + def json(self): + return { + "id": self.id, + "name": self.name, + "price": self.price, + "store_id": self.store_id, + "tags": [tag.json() for tag in self.tags], + } + + @classmethod + def find_by_name(cls, name): + return cls.query.filter_by(name=name).first() + + @classmethod + def find_all(cls): + return cls.query.all() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/project/using-flask-restx/models/item_tags.py b/project/using-flask-restx/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/project/using-flask-restx/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/section6/models/store.py b/project/using-flask-restx/models/store.py similarity index 50% rename from section6/models/store.py rename to project/using-flask-restx/models/store.py index 33e6f053..e3dad08d 100644 --- a/section6/models/store.py +++ b/project/using-flask-restx/models/store.py @@ -2,23 +2,28 @@ class StoreModel(db.Model): - __tablename__ = 'stores' + __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(80)) + name = db.Column(db.String(80), unique=True, nullable=False) - items = db.relationship('ItemModel', lazy='dynamic') - - def __init__(self, name): - self.name = name + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") def json(self): - return {'name': self.name, 'items': [item.json() for item in self.items.all()]} + return { + "id": self.id, + "name": self.name, + "items": [item.json() for item in self.items.all()], + } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() + @classmethod + def find_all(cls): + return cls.query.all() + def save_to_db(self): db.session.add(self) db.session.commit() diff --git a/project/using-flask-restx/models/tag.py b/project/using-flask-restx/models/tag.py new file mode 100644 index 00000000..23332527 --- /dev/null +++ b/project/using-flask-restx/models/tag.py @@ -0,0 +1,32 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") + + def json(self): + return { + "id": self.id, + "name": self.name, + "items": [item.name for item in self.items], + } + + @classmethod + def find_by_name(cls, name): + return cls.query.filter_by(name=name).first() + + @classmethod + def find_all(cls): + return cls.query.all() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/section6/models/user.py b/project/using-flask-restx/models/user.py similarity index 55% rename from section6/models/user.py rename to project/using-flask-restx/models/user.py index 30b7fe71..585afce9 100644 --- a/section6/models/user.py +++ b/project/using-flask-restx/models/user.py @@ -5,16 +5,14 @@ class UserModel(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(80)) - password = db.Column(db.String(80)) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) - def __init__(self, username, password): - self.username = username - self.password = password - - def save_to_db(self): - db.session.add(self) - db.session.commit() + def json(self): + return { + 'id': self.id, + 'username': self.username + } @classmethod def find_by_username(cls, username): @@ -23,3 +21,11 @@ def find_by_username(cls, username): @classmethod def find_by_id(cls, _id): return cls.query.filter_by(id=_id).first() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/project/using-flask-restx/requirements.txt b/project/using-flask-restx/requirements.txt new file mode 100644 index 00000000..5d59d488 --- /dev/null +++ b/project/using-flask-restx/requirements.txt @@ -0,0 +1,6 @@ +Flask-JWT-Extended +Flask-RESTX +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv \ No newline at end of file diff --git a/project/using-flask-restx/resources/__init__.py b/project/using-flask-restx/resources/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/project/using-flask-restx/resources/__init__.py @@ -0,0 +1 @@ + diff --git a/project/using-flask-restx/resources/item.py b/project/using-flask-restx/resources/item.py new file mode 100644 index 00000000..0ee38297 --- /dev/null +++ b/project/using-flask-restx/resources/item.py @@ -0,0 +1,101 @@ +from flask import request +from flask_restx import Namespace, Resource, fields, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError +from models import ItemModel + +api = Namespace("items", description="Operations related to store items.") + +item_inputs = api.model( + "ItemFields", + { + "price": fields.Float(required=True, description="A price for this item."), + "store_id": fields.Integer( + required=True, + description="The identifier for the store that this item belongs to.", + ), + }, +) + +nested_resource = api.model( + "NestedResource", {"id": fields.Integer(), "name": fields.String()} +) + +item_outputs = api.inherit( + "Item", + item_inputs, + { + "id": fields.Integer(), + "name": fields.String(), + "store": fields.Nested(nested_resource), + "tags": fields.List(fields.Nested(nested_resource)), + }, +) + + +@api.route("/") +@api.param("name", "The unique name for the item you want to interact with.") +@api.doc( + responses={ + 404: "Item not found.", + 400: "Bad request (name already exists or validation error).", + 500: "An error occurred while inserting that item.", + } +) +class Item(Resource): + @jwt_required() + @api.marshal_with(item_outputs) + def get(self, name): + item = ItemModel.find_by_name(name) + if item: + return item + abort(404, "Item not found") + + @jwt_required(fresh=True) + @api.expect(item_inputs, validate=True) + @api.marshal_with(item_outputs) + def post(self, name): + if ItemModel.find_by_name(name): + abort(400, f"An item with name {name} already exists.") + + item = ItemModel(name=name, **request.get_json()) + + try: + item.save_to_db() + except SQLAlchemyError: + abort(500, "An error occurred while inserting the item.") + + return item, 201 + + @jwt_required() + def delete(self, name): + jwt = get_jwt() + if not jwt["is_admin"]: + abort(401, "Admin privilege required.") + + item = ItemModel.find_by_name(name) + if item: + item.delete_from_db() + return {"message": "Item deleted."} + abort(404, "Item not found.") + + @api.expect(item_inputs, validate=True) + @api.marshal_with(item_outputs) + def put(self, name): + item = ItemModel.find_by_name(name) + + if item: + item.price = request.get_json()["price"] + else: + item = ItemModel(name, **request.get_json()) + + item.save_to_db() + return item + + +@api.route("/") +class ItemList(Resource): + @api.marshal_list_with(item_outputs) + def get(self): + items = ItemModel.find_all() + return items, 200 diff --git a/project/using-flask-restx/resources/store.py b/project/using-flask-restx/resources/store.py new file mode 100644 index 00000000..3d2f1928 --- /dev/null +++ b/project/using-flask-restx/resources/store.py @@ -0,0 +1,62 @@ +from flask import abort +from flask_restx import Namespace, Resource, fields +from sqlalchemy.exc import SQLAlchemyError +from models import StoreModel + + +api = Namespace("stores", description="Operations related to stores.") + +nested_item = api.model( + "NestedItem", + { + "id": fields.Integer(), + "name": fields.String(), + "price": fields.Float(), + }, +) + +store_outputs = api.model( + "Store", + { + "id": fields.Integer(), + "name": fields.String(), + "items": fields.List(fields.Nested(nested_item)), + }, +) + + +@api.route("/") +class Store(Resource): + @api.marshal_with(store_outputs) + def get(self, name): + store = StoreModel.find_by_name(name) + if store: + return store + abort(404, "Store not found.") + + @api.marshal_with(store_outputs) + def post(self, name): + if StoreModel.find_by_name(name): + abort(400, f"A store with name '{name}' already exists.") + + store = StoreModel(name=name) + try: + store.save_to_db() + except SQLAlchemyError: + abort(500, "An error occurred creating the store.") + + return store, 201 + + def delete(self, name): + store = StoreModel.find_by_name(name) + if store: + store.delete_from_db() + return {"message": "Store deleted"}, 200 + abort(404, "Store not found.") + + +@api.route("/") +class StoreList(Resource): + @api.marshal_list_with(store_outputs) + def get(self): + return StoreModel.find_all() diff --git a/project/using-flask-restx/resources/tag.py b/project/using-flask-restx/resources/tag.py new file mode 100644 index 00000000..aea27445 --- /dev/null +++ b/project/using-flask-restx/resources/tag.py @@ -0,0 +1,119 @@ +from flask import abort, request +from flask_restx import Namespace, Resource, fields +from werkzeug.exceptions import BadRequest +from sqlalchemy.exc import SQLAlchemyError +from models import TagModel +from models import ItemModel + +api = Namespace( + "tags", description="Operations related to tags and their relationship to items." +) + +item_id = api.model("ItemId", {"item_id": fields.Integer()}) + +nested_item = api.inherit( + "NestedItem", + item_id, + { + "name": fields.String(), + "price": fields.Float(), + }, +) + +nested_tag = api.model( + "NestedTag", + { + "id": fields.Integer(), + "name": fields.String(), + }, +) + +tag_outputs = api.inherit( + "Tag", + nested_tag, + { + "items": fields.List(fields.Nested(nested_item)), + }, +) + + +@api.route("/") +class Tag(Resource): + @api.marshal_with(tag_outputs) + def get(self, name): + tag = TagModel.find_by_name(name) + if tag: + return tag + abort(404, "Tag not found.") + + @api.marshal_with(tag_outputs) + def post(self, name): + json_input = request.get_json() + tag = TagModel.find_by_name(name) + if not tag: + tag = TagModel(name=name) + + # Add the item to the tag + try: + item = ItemModel.query.get(json_input["item_id"]) + + if not item: + abort(400, "An item with this item_id doesn't exist.") + + tag.items.append(item) + except (TypeError, KeyError): + abort(400, "Missing required field 'item_id' in JSON body.") + + try: + tag.save_to_db() + except SQLAlchemyError: + abort(500, "An error occurred while inserting the tag.") + + return tag, 201 + + def delete(self, name): + tag = TagModel.find_by_name(name) + if not tag: + abort(404, "Tag not found.") + + if not tag.items: + tag.delete_from_db() + return {"message": f"Tag '{name}' deleted."} + abort( + 400, + "Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) + + +@api.route("//remove") +class RemoveItemFromTag(Resource): + @api.expect(item_id, validate=True) + def delete(self, name): + tag = TagModel.find_by_name(name) + if not tag: + abort(404, "Tag not found.") + + try: + item_id = request.get_json()["item_id"] + item = ItemModel.query.get(item_id) + try: + tag.items.remove(item) + except ValueError: + abort( + 400, + f"Could not remove item with id '{item_id}' from tag." + "Make sure item is associated with that item.", + ) + return {"message": f"Item with id '{item_id}' removed from tag."} + except BadRequest: + abort( + 400, + "Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) + + +@api.route("/") +class TagList(Resource): + @api.marshal_list_with(tag_outputs) + def get(self): + return TagModel.find_all() diff --git a/project/using-flask-restx/resources/user.py b/project/using-flask-restx/resources/user.py new file mode 100644 index 00000000..df5088d7 --- /dev/null +++ b/project/using-flask-restx/resources/user.py @@ -0,0 +1,101 @@ +from flask import abort, request +from flask_restx import Namespace, Resource, fields +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from models import UserModel +from blocklist import BLOCKLIST + +api = Namespace("users", description="Operations related to users and authentication.") + +user_inputs = api.model( + "UserFields", + { + "username": fields.String(required=True), + "password": fields.String(required=True), + }, +) + +user_outputs = api.model("User", {"id": fields.String(), "username": fields.String()}) + + +@api.route("/register") +class UserRegister(Resource): + @api.expect(user_inputs, validate=True) + def post(self): + user_data = request.get_json() + + if UserModel.find_by_username(user_data["username"]): + abort(400, "A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + user.save_to_db() + + return {"message": "User created successfully."}, 201 + + +@api.route("/login") +class UserLogin(Resource): + @api.expect(user_inputs, validate=True) + def post(self): + user_data = request.get_json() + + user = UserModel.find_by_username(user_data["username"]) + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, "Invalid credentials.") + + +@api.route("/logout") +class UserLogout(Resource): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@api.route("/user/") +class User(Resource): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @api.marshal_with(user_outputs) + def get(cls, user_id): + user = UserModel.find_by_id(user_id) + if not user: + abort(404, "User not found.") + return user, 200 + + def delete(self, user_id): + user = UserModel.find_by_id(user_id) + if not user: + abort(404, "User not found.") + user.delete_from_db() + return {"message": "User deleted."}, 200 + + +@api.route("/refresh") +class TokenRefresh(Resource): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token}, 200 diff --git a/project/using-flask-smorest-docker b/project/using-flask-smorest-docker new file mode 160000 index 00000000..d293eb98 --- /dev/null +++ b/project/using-flask-smorest-docker @@ -0,0 +1 @@ +Subproject commit d293eb9867c13d56a302e898bb0c41dd186b265b diff --git a/project/using-flask-smorest/.flaskenv b/project/using-flask-smorest/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/project/using-flask-smorest/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/project/using-flask-smorest/Flask-JWT-Extended.postman_collection.json b/project/using-flask-smorest/Flask-JWT-Extended.postman_collection.json new file mode 100644 index 00000000..4f5fd5fc --- /dev/null +++ b/project/using-flask-smorest/Flask-JWT-Extended.postman_collection.json @@ -0,0 +1,483 @@ +{ + "info": { + "_postman_id": "74a1833f-bc4e-4e85-a525-72d268ab9999", + "name": "Flask-JWT-Extended", + "description": "This collection contains requests associated witht the Flask-JWT-Extended section of the REST API course.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "users", + "description": "", + "item": [ + { + "name": "register a new user", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" + }, + "url": { + "raw": "{{server_address}}/register", + "host": [ + "{{server_address}}" + ], + "path": [ + "register" + ] + } + }, + "response": [] + }, + { + "name": "get user by id", + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" + }, + "url": { + "raw": "{{server_address}}/user/1", + "host": [ + "{{server_address}}" + ], + "path": [ + "user", + "1" + ] + } + }, + "response": [] + }, + { + "name": "delete user by id", + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" + }, + "url": { + "raw": "{{server_address}}/user/2", + "host": [ + "{{server_address}}" + ], + "path": [ + "user", + "2" + ] + } + }, + "response": [] + }, + { + "name": "login", + "event": [ + { + "listen": "test", + "script": { + "id": "8c0c0ed6-c206-4c88-9349-429e024e312b", + "type": "text/javascript", + "exec": [ + "var jsonData = pm.response.json();", + "pm.test(\"access_token not empty\", function () {", + " pm.expect(jsonData.access_token).not.eql(undefined);", + "});", + "", + "pm.test(\"refresh token not empty\", function () {", + " pm.expect(jsonData.refresh_token).not.eql(undefined);", + "});", + "// set access token as environement variable", + "if (jsonData.access_token !== undefined) {", + " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", + "} else {", + " postman.setEnvironmentVariable(\"access_token\", null);", + "}", + "// set refresh token as environement variable", + "if (jsonData.refresh_token !== undefined) {", + " postman.setEnvironmentVariable(\"refresh_token\", jsonData.refresh_token);", + "} else {", + " postman.setEnvironmentVariable(\"refresh_token\", null);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"username\" : \"jose\",\n \"password\" : \"1234\"\n}" + }, + "url": { + "raw": "{{server_address}}/login", + "host": [ + "{{server_address}}" + ], + "path": [ + "login" + ] + } + }, + "response": [] + }, + { + "name": "logout", + "event": [ + { + "listen": "test", + "script": { + "id": "dc763e9b-e6c7-4ff3-9766-637976a5c64b", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{server_address}}/logout", + "host": [ + "{{server_address}}" + ], + "path": [ + "logout" + ] + } + }, + "response": [] + }, + { + "name": "refresh token", + "event": [ + { + "listen": "test", + "script": { + "id": "ad818ea6-8f79-436e-b756-ad878666ae9e", + "type": "text/javascript", + "exec": [ + "var jsonData = pm.response.json();", + "pm.test(\"access_token not empty\", function () {", + " pm.expect(jsonData.access_token).not.eql(undefined);", + "});", + "// set access token as environement variable", + "if (jsonData.access_token !== undefined) {", + " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", + "} else {", + " postman.setEnvironmentVariable(\"access_token\", null);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{refresh_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{local_flask}}/refresh", + "host": [ + "{{local_flask}}" + ], + "path": [ + "refresh" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "items", + "description": "", + "item": [ + { + "name": "get item/name", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": {}, + "url": { + "raw": "{{local_flask}}/item/chair", + "host": [ + "{{local_flask}}" + ], + "path": [ + "item", + "chair" + ] + } + }, + "response": [] + }, + { + "name": "post item/name", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" + }, + "url": { + "raw": "{{local_flask}}/item/chair", + "host": [ + "{{local_flask}}" + ], + "path": [ + "item", + "chair" + ] + } + }, + "response": [] + }, + { + "name": "put item/name", + "request": { + "method": "PUT", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" + }, + "url": { + "raw": "{{local_flask}}/item/chair", + "host": [ + "{{local_flask}}" + ], + "path": [ + "item", + "chair" + ] + } + }, + "response": [] + }, + { + "name": "delete item by name", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{local_flask}}/item/chair", + "host": [ + "{{local_flask}}" + ], + "path": [ + "item", + "chair" + ] + } + }, + "response": [] + }, + { + "name": "get all items", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" + }, + "url": { + "raw": "{{local_flask}}/item", + "host": [ + "{{local_flask}}" + ], + "path": [ + "items" + ] + } + }, + "response": [] + }, + { + "name": "get all items without JWT", + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" + }, + "url": { + "raw": "{{local_flask}}/item", + "host": [ + "{{local_flask}}" + ], + "path": [ + "items" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "stores", + "description": "", + "item": [ + { + "name": "create a new store", + "request": { + "method": "POST", + "header": [], + "body": {}, + "url": { + "raw": "{{server_address}}/store/My Wonderful Store", + "host": [ + "{{server_address}}" + ], + "path": [ + "store", + "My Wonderful Store" + ] + } + }, + "response": [] + }, + { + "name": "get store by name", + "request": { + "method": "GET", + "header": [], + "body": {}, + "url": { + "raw": "{{server_address}}/store/My Wonderful Store", + "host": [ + "{{server_address}}" + ], + "path": [ + "store", + "My Wonderful Store" + ] + } + }, + "response": [] + }, + { + "name": "delete a new store by name", + "request": { + "method": "DELETE", + "header": [], + "body": {}, + "url": { + "raw": "{{server_address}}/store/My Wonderful Store", + "host": [ + "{{server_address}}" + ], + "path": [ + "store", + "My Wonderful Store" + ] + } + }, + "response": [] + }, + { + "name": "get all stores", + "request": { + "method": "GET", + "header": [], + "body": {}, + "url": { + "raw": "{{server_address}}/store", + "host": [ + "{{server_address}}" + ], + "path": [ + "stores" + ] + } + }, + "response": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/project/using-flask-smorest/Stores_REST_API_2022-01-14.json b/project/using-flask-smorest/Stores_REST_API_2022-01-14.json new file mode 100644 index 00000000..400012df --- /dev/null +++ b/project/using-flask-smorest/Stores_REST_API_2022-01-14.json @@ -0,0 +1 @@ +{"_type":"export","__export_format":4,"__export_date":"2022-01-14T11:50:51.742Z","__export_source":"insomnia.desktop.app:v2021.7.2","resources":[{"_id":"req_efcadee1c4fc48f099644e23398a5d29","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159057139,"created":1642157007062,"url":"{{url}}/register","name":"/register","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_64c3752c7f694f0aa830bacba3b35aea"},{"name":"Authorization","value":"JWT","id":"pair_d143b36c4aa74f9681dc1590970da3b7"}],"authentication":{},"metaSortKey":-1642157660252,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_fd1f956aae16470fafdc3d611d34a80a","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157670592,"created":1642157670592,"name":"Authentication","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157670592,"_type":"request_group"},{"_id":"wrk_c441bf446d174d1bb2f01c7ad66c695b","parentId":null,"modified":1642157007080,"created":1642149963161,"name":"Stores REST API","description":"","scope":"collection","_type":"workspace"},{"_id":"req_16415c75944342dab73119513e7bd20b","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159087108,"created":1642157007061,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_a8caec1064eb43b7ac5c8c9294be13a3"}],"authentication":{},"metaSortKey":-1642157660202,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b08c51961bea4413a31fba1af93b3759","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642159093249,"created":1642157007054,"url":"{{url}}/item/my_item","name":"/item/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": 3\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"},{"id":"pair_4926e48dcb594eaa9c79a78b801b708f","name":"Authorization","value":"Bearer {% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'always', 60 %}","description":""}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_5bf669c32c3145a3a80dee2d6523f9ac","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157649712,"created":1642157649712,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157649712,"_type":"request_group"},{"_id":"req_e553e5091f714becb81e1b27bfc8f34b","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642160992156,"created":1642157007053,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"Bearer {{access_token}}","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1f64b6c8fc8642aa9c267c8d49c72435","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157976127,"created":1642157007052,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_62498017fcb34ba0a3a19b4e0f2d4499","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_69d7ed86b4dc4b72a72778f97a77e05c","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157658823,"created":1642157007048,"url":"{{url}}/item","name":"/item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007178.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_054be716de114cc49d6e49d04a5a901b","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642159101503,"created":1642157684047,"url":"{{url}}/tag/my_tag","name":"/tag/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"item_id\": {% response 'body', 'req_e553e5091f714becb81e1b27bfc8f34b', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_adac84f9834d4e948ceb02807787c935","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157684028,"created":1642157684028,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157641282.5,"_type":"request_group"},{"_id":"req_a5b2631adf9e4ef894a8a1b9d2c77aa8","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158069229,"created":1642157684046,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"JWT ","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5dbef7a9b30d4ee68c148c4af447c241","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158073367,"created":1642157684041,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7e8e3838e1cb41b485e091bf667b0764","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157645538,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320140.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157632853,"created":1642157632853,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157632853,"_type":"request_group"},{"_id":"req_f99ce0192797434f99657221acc45fe3","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157643850,"created":1642157007059,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320090.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_82705a91a36849b09f1347d135816761","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157642682,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320040.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_94efb5c0488d43d8be95fd82b33afb97","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157639015,"created":1642157007060,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157319990.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9bc4db6f4d02466aba86edef29722854","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157781373,"created":1642157007070,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_8c8f8b7b9ddd4c3ca7fb6df5418b7f2e"}],"authentication":{},"metaSortKey":-1642157007070,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_05fce0fdc405492d8a7f87842e6d4e13","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157017423,"created":1642157007072,"name":"User create store and item","description":"Check user can register.\nCheck user can create store.\nCheck user can create item in store.","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157007128,"_type":"request_group"},{"_id":"req_2ce4ecd840094ac1a164d7a0bfbb6d83","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157887543,"created":1642157007069,"url":"{{url}}/store/test_store","name":"/store/test_store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007069,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8c68f13e77a74937b62cde1ae24bed61","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157923539,"created":1642157007068,"url":"{{url}}/item/test_item","name":"/item/test_item in test_store","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": {% response 'body', 'req_2ce4ecd840094ac1a164d7a0bfbb6d83', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_d0c5451b8b044511b76436c627ffc4bb"},{"id":"pair_eb8ca7f686334ae5a48ad48412436ad9","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007068,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_297f6099ee274c1f8ccceb3bc29ad582","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007065,"created":1642157007065,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007065,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_84a807bef7cd4a66bc81f5401f0639cd","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157931016,"created":1642157007064,"url":"{{url}}/item/test_item","name":"/item/my_item copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_daca8133eb94474ca84748a0e4c8bcaf","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007064,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_10076b1332f2458e897d7b5200c7e5de","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007063,"created":1642157007063,"url":"{{url}}/store/test_store","name":"/store/ copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007063,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642160933906,"created":1642149963165,"name":"Base Environment","data":{"url":"http://127.0.0.1:5000","access_token":"{% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'never', 60 %}"},"dataPropertyOrder":{"&":["url","access_token"]},"color":null,"isPrivate":false,"metaSortKey":1642149963165,"_type":"environment"},{"_id":"jar_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963166,"created":1642149963166,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_f25b8aff2219447aa56189a385b1663c","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963162,"created":1642149963162,"fileName":"Stores REST API","contents":"","contentType":"yaml","_type":"api_spec"}]} \ No newline at end of file diff --git a/project/using-flask-smorest/app.py b/project/using-flask-smorest/app.py new file mode 100644 index 00000000..a6b06786 --- /dev/null +++ b/project/using-flask-smorest/app.py @@ -0,0 +1,116 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +app = Flask(__name__) +app.config["API_TITLE"] = "Stores REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.3" +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["PROPAGATE_EXCEPTIONS"] = True +db.init_app(app) +api = Api(app) + +""" +JWT related configuration. The following functions includes: +1) add claims to each jwt +2) customize the token expired error message +""" +app.config["JWT_SECRET_KEY"] = "jose" +jwt = JWTManager(app) + +""" +`claims` are data we choose to attach to each jwt payload +and for each jwt protected endpoint, we can retrieve these claims via `get_jwt_claims()` +one possible use case for claims are access level control, which is shown below +""" + + +@jwt.additional_claims_loader +def add_claims_to_jwt(identity): + # TODO: Read from a config file instead of hard-coding + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + +@jwt.token_in_blocklist_loader +def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + +@jwt.expired_token_loader +def expired_token_callback(jwt_header, jwt_payload): + return jsonify({"message": "The token has expired.", "error": "token_expired"}), 401 + + +@jwt.invalid_token_loader +def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + +@jwt.unauthorized_loader +def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + +@jwt.needs_fresh_token_loader +def token_not_fresh_callback(): + return ( + jsonify( + {"description": "The token is not fresh.", "error": "fresh_token_required"} + ), + 401, + ) + + +@jwt.revoked_token_loader +def revoked_token_callback(): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + +# JWT configuration ends + + +@app.before_first_request +def create_tables(): + import models # noqa: F401 + + db.create_all() + + +api.register_blueprint(UserBlueprint) +api.register_blueprint(ItemBlueprint) +api.register_blueprint(StoreBlueprint) +api.register_blueprint(TagBlueprint) diff --git a/project/using-flask-smorest/blocklist.py b/project/using-flask-smorest/blocklist.py new file mode 100644 index 00000000..ef2383ba --- /dev/null +++ b/project/using-flask-smorest/blocklist.py @@ -0,0 +1,9 @@ +""" +blacklist.py + +This file just contains the blacklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blacklist when the +user logs out. +""" + +BLOCKLIST = set() diff --git a/project/using-flask-smorest/db.py b/project/using-flask-smorest/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/project/using-flask-smorest/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/project/using-flask-smorest/models/__init__.py b/project/using-flask-smorest/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/project/using-flask-smorest/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/project/using-flask-smorest/models/item.py b/project/using-flask-smorest/models/item.py new file mode 100644 index 00000000..9995b296 --- /dev/null +++ b/project/using-flask-smorest/models/item.py @@ -0,0 +1,41 @@ +from db import db + + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + + store_id = db.Column( + db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False + ) + store = db.relationship("StoreModel", back_populates="items") + + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") + + def json(self): + return { + "id": self.id, + "name": self.name, + "price": self.price, + "store_id": self.store_id, + "tags": [tag.json() for tag in self.tags], + } + + @classmethod + def find_by_name(cls, name): + return cls.query.filter_by(name=name).first() + + @classmethod + def find_all(cls): + return cls.query.all() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/project/using-flask-smorest/models/item_tags.py b/project/using-flask-smorest/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/project/using-flask-smorest/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) diff --git a/project/using-flask-smorest/models/store.py b/project/using-flask-smorest/models/store.py new file mode 100644 index 00000000..e3dad08d --- /dev/null +++ b/project/using-flask-smorest/models/store.py @@ -0,0 +1,33 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + + def json(self): + return { + "id": self.id, + "name": self.name, + "items": [item.json() for item in self.items.all()], + } + + @classmethod + def find_by_name(cls, name): + return cls.query.filter_by(name=name).first() + + @classmethod + def find_all(cls): + return cls.query.all() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/project/using-flask-smorest/models/tag.py b/project/using-flask-smorest/models/tag.py new file mode 100644 index 00000000..23332527 --- /dev/null +++ b/project/using-flask-smorest/models/tag.py @@ -0,0 +1,32 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") + + def json(self): + return { + "id": self.id, + "name": self.name, + "items": [item.name for item in self.items], + } + + @classmethod + def find_by_name(cls, name): + return cls.query.filter_by(name=name).first() + + @classmethod + def find_all(cls): + return cls.query.all() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/project/using-flask-smorest/models/user.py b/project/using-flask-smorest/models/user.py new file mode 100644 index 00000000..585afce9 --- /dev/null +++ b/project/using-flask-smorest/models/user.py @@ -0,0 +1,31 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(80), unique=True, nullable=False) + + def json(self): + return { + 'id': self.id, + 'username': self.username + } + + @classmethod + def find_by_username(cls, username): + return cls.query.filter_by(username=username).first() + + @classmethod + def find_by_id(cls, _id): + return cls.query.filter_by(id=_id).first() + + def save_to_db(self): + db.session.add(self) + db.session.commit() + + def delete_from_db(self): + db.session.delete(self) + db.session.commit() diff --git a/project/using-flask-smorest/requirements.txt b/project/using-flask-smorest/requirements.txt new file mode 100644 index 00000000..fbde7226 --- /dev/null +++ b/project/using-flask-smorest/requirements.txt @@ -0,0 +1,6 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv \ No newline at end of file diff --git a/project/using-flask-smorest/resources/__init__.py b/project/using-flask-smorest/resources/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/project/using-flask-smorest/resources/__init__.py @@ -0,0 +1 @@ + diff --git a/project/using-flask-smorest/resources/item.py b/project/using-flask-smorest/resources/item.py new file mode 100644 index 00000000..6a657f51 --- /dev/null +++ b/project/using-flask-smorest/resources/item.py @@ -0,0 +1,70 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import get_jwt_identity, jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", "items", description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, name): + item = ItemModel.find_by_name(name) + if item: + return item + abort(404, message="Item not found") + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data, name): + if ItemModel.find_by_name(name): + abort(400, message=f"An item with name {name} already exists.") + + item = ItemModel(**item_data, name=name) + + try: + item.save_to_db() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the item.") + + return item + + @jwt_required() + def delete(self, name): + jwt = get_jwt() + if not jwt["is_admin"]: + abort(401, message="Admin privilege required.") + + item = ItemModel.find_by_name(name) + if item: + item.delete_from_db() + return {"message": "Item deleted."} + abort(404, message="Item not found.") + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, name): + item = ItemModel.find_by_name(name) + + if item: + item.price = item_data["price"] + else: + item = ItemModel(name, **item_data) + + item.save_to_db() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.find_all() diff --git a/project/using-flask-smorest/resources/store.py b/project/using-flask-smorest/resources/store.py new file mode 100644 index 00000000..8e4adcf9 --- /dev/null +++ b/project/using-flask-smorest/resources/store.py @@ -0,0 +1,45 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("Stores", "stores", description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(cls, name): + store = StoreModel.find_by_name(name) + if store: + return store + abort(404, message="Store not found.") + + @blp.response(201, StoreSchema) + def post(cls, name): + if StoreModel.find_by_name(name): + abort(400, message=f"A store with name '{name}' already exists.") + + store = StoreModel(name=name) + try: + store.save_to_db() + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store + + def delete(cls, name): + store = StoreModel.find_by_name(name) + if store: + store.delete_from_db() + return {"message": "Store deleted"}, 200 + abort(404, message="Store not found.") + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(cls): + return StoreModel.find_all() diff --git a/project/using-flask-smorest/resources/tag.py b/project/using-flask-smorest/resources/tag.py new file mode 100644 index 00000000..063eddf4 --- /dev/null +++ b/project/using-flask-smorest/resources/tag.py @@ -0,0 +1,85 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from werkzeug.exceptions import BadRequest +from sqlalchemy.exc import SQLAlchemyError +from models import TagModel +from models import ItemModel +from schemas import TagSchema, TagUpdateSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, name): + tag = TagModel.find_by_name(name) + if tag: + return tag + abort(404, message="Tag not found.") + + @blp.arguments(TagUpdateSchema) + @blp.response(201, TagSchema) + def post(self, update_data, name): + tag = TagModel.find_by_name(name) + if not tag: + tag = TagModel(name=name) + + # Add the item to the tag + try: + item = ItemModel.query.get(update_data["item_id"]) + + if not item: + abort(400, message="An item with this item_id doesn't exist.") + + tag.items.append(item) + except (TypeError, KeyError): + abort(400, message="Missing required field 'item_id' in JSON body.") + + try: + tag.save_to_db() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.arguments(TagUpdateSchema, required=False) + @blp.response(200, TagAndItemSchema) + @blp.alt_response( + 202, + description="Deletes a tag when it has no items and no item_id is passed in the body.", + example={"message": "Tag deleted."}, + success=True, + ) + @blp.alt_response(404, description="Tag not found") + @blp.alt_response( + 400, description="Missing item_id in body when tag is associated to items." + ) + def delete(self, tag_data, name): + """Deletes a tag. + + If the tag is associated to items, expects an item_id in the JSON body and unlinks the item from the tag. + + If the tag is not associated to any items, then does not expect item_id in the JSON body and deletes the tag entirely. + """ + tag = TagModel.find_by_name(name) + if "item_id" in tag_data: + item = ItemModel.query.get(tag_data["item_id"]) + tag.items.remove(item) + tag.save_to_db() + return { + "message": "Item removed from tag", + "item": item, + "tag": tag, + } + else: + # Assume no item_id was passed. Instead delete entire tag. + # First check tag has no items + if not tag.items: + tag.delete_from_db() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 + ) + abort(404, message="Tag not found.") diff --git a/project/using-flask-smorest/resources/user.py b/project/using-flask-smorest/resources/user.py new file mode 100644 index 00000000..807dc8f4 --- /dev/null +++ b/project/using-flask-smorest/resources/user.py @@ -0,0 +1,89 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.find_by_username(user_data["username"]): + abort(400, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + user.save_to_db() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.find_by_username(user_data["username"]) + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.find_by_id(user_id) + if not user: + abort(404, message="User not found.") + return user + + def delete(self, user_id): + user = UserModel.find_by_id(user_id) + if not user: + abort(404, message="User not found.") + user.delete_from_db() + return {"message": "User deleted."}, 200 + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token}, 200 diff --git a/project/using-flask-smorest/schemas.py b/project/using-flask-smorest/schemas.py new file mode 100644 index 00000000..576dc04a --- /dev/null +++ b/project/using-flask-smorest/schemas.py @@ -0,0 +1,67 @@ +from marshmallow import Schema, fields + + +class ItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True, dump_only=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(lambda: StoreWitoutItemsSchema(), dump_only=True) + tags = fields.List(fields.Nested(lambda: TagWithoutItemsSchema()), dump_only=True) + + +class ItemWithoutStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True, dump_only=True) + price = fields.Float(required=True) + tags = fields.List(fields.Nested(lambda: TagWithoutItemsSchema()), dump_only=True) + + +class ItemWithoutTagsSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True, dump_only=True) + price = fields.Float(required=True) + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(lambda: StoreWitoutItemsSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + price = fields.Float(required=True) + + +class StoreSchema(Schema): + id = fields.Int() + name = fields.Str() + items = fields.List(fields.Nested(ItemWithoutStoreSchema()), dump_only=True) + + +class StoreWitoutItemsSchema(Schema): + id = fields.Int() + name = fields.Str() + + +class TagSchema(Schema): + id = fields.Int() + name = fields.Str() + items = fields.List(fields.Nested(ItemWithoutTagsSchema()), dump_only=True) + + +class TagWithoutItemsSchema(Schema): + id = fields.Int() + name = fields.Str() + + +class TagUpdateSchema(Schema): + item_id = fields.Int() + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int() + username = fields.Str() + password = fields.Str(load_only=True) diff --git a/section11/README.md b/section11/README.md deleted file mode 100644 index b5593feb..00000000 --- a/section11/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# Flask-jwt-extended Section - -This section presents the basic usage of an active flask JWT extension called `flask-jwt-extended`. We inherited and simplified the project structure from section 6 to demonstrate how to apply `flask-jwt-extended` to our project. - -## Features - - JWT authentication - - Token refreshing - - Fresh token vs. Non-fresh token - - Adding claims to JWT payload - - Blacklist and token revoking - - Customize JWT response/error message callbacks - -### JWT authentication - -Very much the same as before, but we define a UserLogin resource ourselves, as opposed to having a `/auth` endpoint created internally. And we no longer need the `security.py` file consequently. - -### Token Refreshing - -It allows the user login without having to entering the username and password over and over again. When logging in, we get an access token as before, and we also get a refresh token. We can use this refresh token to generate new access tokens without enter user credentials when the current access token expires. - -### Fresh token vs. Non-fresh token - -When generating an access token, we can pass in an optional boolean argument `fresh`. We usually want to distinguish if the token is generated via credentials or via refresh token. Since the one generated with credentials should be considered more secure. So what we did in the project is to return a **fresh** access token and a refresh token from the `/login` endpoint, since user would need to provide their credentials to authenticate through `/login`. As for the `/refresh` endpoint, it only requires a refresh token and no credentials, so it would return a non-fresh access token. The non-fresh access token still gives us some belief that it's the user himself, but it is not that secure. So it's okay to allow the user to access most endpoints using a non-fresh token, but we may want the user to input his credentials again (to get a fresh access token) when performing some more important actions, such as deleting things or payment. - -### Adding claims to JWT payload - -We can also add some extra data to each JWT payload, and these data are accessible within the endpoint. We refer to these data as `claims`. We can attach any claims we find necessary to the JWT payload. In this section, we showed one use case, where we check if the authenticated user is an admin, and added a boolean claim `is_admin` to the payload. Then in each JWT-protected endpoint, we can tell if the user is ad admin, and decide what actions should be taken from there. - -### Blacklist and token revoking - -We can blacklist a user or revoke a token (access token or refresh token) and disable the user from logging in and accessing a protected endpoint. Possible use cases for this feature include: if a user complains his account being compromised, or we decide to take down an existing account temporarily, we can blacklist the user and revoke the token thus prevent the user from logging in. - -### Customize JWT messages - -The default response/error handler may not have the nicest format of output. It uses clueless abbreviation such as `iat` for `issue at time` and `msg` for `message`. We can customize the callbacks and display the message we felt more readable. - -## Related Resources - -### UserLogin - -- `POST: /login` - - Description: authenticate a user and ,if authenticated, respond with a fresh access token and a refresh token. - -### TokenRefresh - -- `POST: /refresh` - - Description: This endpoint is used to refresh an expired access token. If the refresh token is valid, respond with a new valid access token (non-fresh). - - Request header: `Authorization Bearer ` - -### Item - -- `GET: /item/` - - Description: get an item by name, require a valid access token to access this endpoint. - - Request header: `Authorization Bearer ` -- `POST: /item/` - - Description: create a new item, require a valid and fresh access token to access this endpoint. - - Request header: `Authorization Bearer ` -- `DELETE: /item/` - - Description: delete an item by name, require a valid access token and admin privilege. - - Request header: `Authorization Bearer ` - -### ItemList - -- `GET: /items` - - Description: get all items, half protected. User can get more detailed info when providing an access token. - - Request header: `Authorization Bearer ` - -## Suggested introduction order - -Here is a recommended flow of modifying the previous code and introduces Flask-JWT-Extended features. - -### Logging in with Flask-JWT-Extended - -We used the `/login` endpoint, defined in `UserLogin` resource. Focus on the similarities and differences from `Flask-JWT` authentication. Do not introduce refresh token and token freshness yet. Talk about JWT protected endpoint as well, since it's also similar with the previous Flask-JWT extension. Use the `Item.get()` endpoint as example. - -tips: -- Resource is now defined by us, not internally by library anymore. -- Customized url -- `@jwt_required` now as opposed to `@jwt_required()` - -### Adding claims to JWT payload - -Introduce the concept of `claims`, it's just the data we choose to attach to the JWT payload. Use the `Item.delete()` endpoint as example, we make it only accessible by authenticated admins. So we need to configure the claims in `app.py` and decide whether a user is an admin, then we add a boolean claim `is_admin` to the JWT payload. - -tips: -- `get_jwt_identity()` now as opposed to `current_identity` -- The identity is just the user id now as opposed to a UserModel object. - -### Half protected endpoints - -Introduce `@jwt_optional` decorator, which makes the endpoint accessible with and without an access token. Change the `ItemList.get()` endpoint to return different response depending on whether the caller is an authenticated user. - -### Token Refresh - -Introduce another endpoint, `/refresh`, for token refreshing. Add a refresh token in the previous `/login` endpoint response, and show how to get a new access token using the refresh token, without entering user credentials. - -tips: -- don't worry about token freshness for now (non-fresh by default) - -### Token freshness - -Talk about security concerns, and then introduce token freshness concept and the recommended way of using fresh/non-fresh access tokens. Then we return a fresh access token in the `/login` endpoint and return a non-fresh access token in the `/refresh` endpoint. Then introduce `@fresh_jwt_required` decorator for `Item.post()` endpoint, and show how non-fresh token would fail to call the endpoint compared to fresh access tokens. - -### Customize response/error callbacks - -Talk about the differences between the error/response messages from the previous `Flask-JWT` messages, it may be not as readable as the previous ones. Show how we can customize the messages. - -### Blacklisting and token revoking - -Keep on talking about customizing callbacks, as well as security issue. Introduce blacklisting and token revoking here. \ No newline at end of file diff --git a/section11/app.py b/section11/app.py deleted file mode 100644 index cea62126..00000000 --- a/section11/app.py +++ /dev/null @@ -1,106 +0,0 @@ -from flask import Flask, jsonify -from flask_restful import Api -from flask_jwt_extended import JWTManager - -from db import db -from blacklist import BLACKLIST -from resources.user import UserRegister, UserLogin, User, TokenRefresh, UserLogout -from resources.item import Item, ItemList -from resources.store import Store, StoreList - -app = Flask(__name__) -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///data.db' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -app.config['PROPAGATE_EXCEPTIONS'] = True -api = Api(app) - -""" -JWT related configuration. The following functions includes: -1) add claims to each jwt -2) customize the token expired error message -""" -app.config['JWT_SECRET_KEY'] = 'jose' # we can also use app.secret like before, Flask-JWT-Extended can recognize both -app.config['JWT_BLACKLIST_ENABLED'] = True # enable blacklist feature -app.config['JWT_BLACKLIST_TOKEN_CHECKS'] = ['access', 'refresh'] # allow blacklisting for access and refresh tokens -jwt = JWTManager(app) - -""" -`claims` are data we choose to attach to each jwt payload -and for each jwt protected endpoint, we can retrieve these claims via `get_jwt_claims()` -one possible use case for claims are access level control, which is shown below -""" -@jwt.user_claims_loader -def add_claims_to_jwt(identity): - if identity == 1: # instead of hard-coding, we should read from a config file to get a list of admins instead - return {'is_admin': True} - return {'is_admin': False} - - -# This method will check if a token is blacklisted, and will be called automatically when blacklist is enabled -@jwt.token_in_blacklist_loader -def check_if_token_in_blacklist(decrypted_token): - return decrypted_token['jti'] in BLACKLIST - - -# The following callbacks are used for customizing jwt response/error messages. -# The original ones may not be in a very pretty format (opinionated) -@jwt.expired_token_loader -def expired_token_callback(): - return jsonify({ - 'message': 'The token has expired.', - 'error': 'token_expired' - }), 401 - - -@jwt.invalid_token_loader -def invalid_token_callback(error): # we have to keep the argument here, since it's passed in by the caller internally - return jsonify({ - 'message': 'Signature verification failed.', - 'error': 'invalid_token' - }), 401 - - -@jwt.unauthorized_loader -def missing_token_callback(error): - return jsonify({ - "description": "Request does not contain an access token.", - 'error': 'authorization_required' - }), 401 - - -@jwt.needs_fresh_token_loader -def token_not_fresh_callback(): - return jsonify({ - "description": "The token is not fresh.", - 'error': 'fresh_token_required' - }), 401 - - -@jwt.revoked_token_loader -def revoked_token_callback(): - return jsonify({ - "description": "The token has been revoked.", - 'error': 'token_revoked' - }), 401 - -# JWT configuration ends - - -@app.before_first_request -def create_tables(): - db.create_all() - - -api.add_resource(Store, '/store/') -api.add_resource(StoreList, '/stores') -api.add_resource(Item, '/item/') -api.add_resource(ItemList, '/items') -api.add_resource(UserRegister, '/register') -api.add_resource(UserLogin, '/login') -api.add_resource(User, '/user/') -api.add_resource(TokenRefresh, '/refresh') -api.add_resource(UserLogout, '/logout') - -if __name__ == '__main__': - db.init_app(app) - app.run(port=5000, debug=True) diff --git a/section11/models/item.py b/section11/models/item.py deleted file mode 100644 index d437e5cc..00000000 --- a/section11/models/item.py +++ /dev/null @@ -1,41 +0,0 @@ -from db import db - - -class ItemModel(db.Model): - __tablename__ = 'items' - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(80)) - price = db.Column(db.Float(precision=2)) - - store_id = db.Column(db.Integer, db.ForeignKey('stores.id')) - store = db.relationship('StoreModel') - - def __init__(self, name, price, store_id): - self.name = name - self.price = price - self.store_id = store_id - - def json(self): - return { - 'id': self.id, - 'name': self.name, - 'price': self.price, - 'store_id': self.store_id - } - - @classmethod - def find_by_name(cls, name): - return cls.query.filter_by(name=name).first() - - @classmethod - def find_all(cls): - return cls.query.all() - - def save_to_db(self): - db.session.add(self) - db.session.commit() - - def delete_from_db(self): - db.session.delete(self) - db.session.commit() diff --git a/section11/requirements.txt b/section11/requirements.txt deleted file mode 100644 index 36042559..00000000 --- a/section11/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask-JWT-Extended -Flask-RESTful -Flask-SQLAlchemy \ No newline at end of file diff --git a/section11/resources/item.py b/section11/resources/item.py deleted file mode 100644 index 061ed281..00000000 --- a/section11/resources/item.py +++ /dev/null @@ -1,86 +0,0 @@ -from flask_restful import Resource, reqparse -from flask_jwt_extended import get_jwt_identity, jwt_required, get_jwt_claims, fresh_jwt_required, jwt_optional -from models.item import ItemModel - - -""" -The following resources contain endpoints that are protected by jwt, -one may need a valid access token, a valid fresh token or a valid token with authorized privilege -to access each endpoint, details can be found in the README.md doc. -""" - - -class Item(Resource): - parser = reqparse.RequestParser() - parser.add_argument('price', - type=float, - required=True, - help="This field cannot be left blank!" - ) - parser.add_argument('store_id', - type=int, - required=True, - help="Every item needs a store_id." - ) - - @jwt_required - def get(self, name): - item = ItemModel.find_by_name(name) - if item: - return item.json() - return {'message': 'Item not found'}, 404 - - @fresh_jwt_required - def post(self, name): - if ItemModel.find_by_name(name): - return {'message': "An item with name '{}' already exists.".format(name)}, 400 - - data = self.parser.parse_args() - - item = ItemModel(name, **data) - - try: - item.save_to_db() - except: - return {"message": "An error occurred while inserting the item."}, 500 - - return item.json(), 201 - - @jwt_required - def delete(self, name): - claims = get_jwt_claims() - if not claims['is_admin']: - return {'message': 'Admin privilege required.'}, 401 - - item = ItemModel.find_by_name(name) - if item: - item.delete_from_db() - return {'message': 'Item deleted.'} - return {'message': 'Item not found.'}, 404 - - def put(self, name): - data = self.parser.parse_args() - - item = ItemModel.find_by_name(name) - - if item: - item.price = data['price'] - else: - item = ItemModel(name, **data) - - item.save_to_db() - - return item.json() - - -class ItemList(Resource): - @jwt_optional - def get(self): - user_id = get_jwt_identity() - items = [item.json() for item in ItemModel.find_all()] - if user_id: - return {'items': items}, 200 - return { - 'items': [item['name'] for item in items], - 'message': 'More data available if you log in.' - }, 200 diff --git a/section11/resources/user.py b/section11/resources/user.py deleted file mode 100644 index 83ce0f27..00000000 --- a/section11/resources/user.py +++ /dev/null @@ -1,91 +0,0 @@ -from flask_restful import Resource, reqparse -from hmac import compare_digest -from flask_jwt_extended import ( - create_access_token, - create_refresh_token, - jwt_refresh_token_required, - get_jwt_identity, - get_raw_jwt, - jwt_required -) -from models.user import UserModel -from blacklist import BLACKLIST - -_user_parser = reqparse.RequestParser() -_user_parser.add_argument('username', - type=str, - required=True, - help="This field cannot be blank." - ) -_user_parser.add_argument('password', - type=str, - required=True, - help="This field cannot be blank." - ) - - -class UserRegister(Resource): - def post(self): - data = _user_parser.parse_args() - - if UserModel.find_by_username(data['username']): - return {"message": "A user with that username already exists"}, 400 - - user = UserModel(data['username'], data['password']) - user.save_to_db() - - return {"message": "User created successfully."}, 201 - - -class UserLogin(Resource): - def post(self): - data = _user_parser.parse_args() - - user = UserModel.find_by_username(data['username']) - - if user and compare_digest(user.password, data['password']): - access_token = create_access_token(identity=user.id, fresh=True) - refresh_token = create_refresh_token(user.id) - return { - 'access_token': access_token, - 'refresh_token': refresh_token - }, 200 - - return {"message": "Invalid Credentials!"}, 401 - - -class UserLogout(Resource): - @jwt_required - def post(self): - jti = get_raw_jwt()['jti'] - BLACKLIST.add(jti) - return {"message": "Successfully logged out"}, 200 - - -class User(Resource): - """ - This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the - sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. - """ - @classmethod - def get(cls, user_id: int): - user = UserModel.find_by_id(user_id) - if not user: - return {'message': 'User Not Found'}, 404 - return user.json(), 200 - - @classmethod - def delete(cls, user_id: int): - user = UserModel.find_by_id(user_id) - if not user: - return {'message': 'User Not Found'}, 404 - user.delete_from_db() - return {'message': 'User deleted.'}, 200 - - -class TokenRefresh(Resource): - @jwt_refresh_token_required - def post(self): - current_user = get_jwt_identity() - new_token = create_access_token(identity=current_user, fresh=False) - return {'access_token': new_token}, 200 diff --git a/section2/10_args_and_kwargs.py b/section2/10_args_and_kwargs.py deleted file mode 100644 index 184c72f7..00000000 --- a/section2/10_args_and_kwargs.py +++ /dev/null @@ -1,39 +0,0 @@ -def my_method(arg1, arg2): - return arg1 + arg2 - -def my_really_long_addition(arg1, arg2, arg3, arg4, arg5): - return arg1 + arg2 + arg3 + arg4 + arg5 - -my_really_long_addition(13, 45, 66, 3, 4) - -def adding_simplified(arg_list): - return sum(arg_list) - -adding_simplified([13, 45, 66, 3, 4]) - -# But you need a list :( - -def what_are_args(*args): - print(args) - -what_are_args(12, 35, 64, 'hello') - -def adding_more_simplified(*args): - return sum(args) # args is a tuple of arguments passed - -adding_more_simplified(13, 45, 66, 3, 4) - -### - -# As well as a tuple of args, we can pass kwargs - -def what_are_kwargs(*args, **kwargs): - print(args) - print(kwargs) - -what_are_kwargs(name='Jose', location='UK') -what_are_kwargs(12, 35, 66, name='Jose', location='UK') - -# args are a tuple -# kwargs is a dictionary -# This will come in handy! diff --git a/section2/11_passing_functions.py b/section2/11_passing_functions.py deleted file mode 100644 index 649ec781..00000000 --- a/section2/11_passing_functions.py +++ /dev/null @@ -1,24 +0,0 @@ -def methodception(another): - return another() - -def add_two_numbers(): - return 35 + 77 - -methodception(add_two_numbers) - -### - -methodception(lambda: 35 + 77) - -my_list = [13, 56, 77, 484] -list(filter(lambda x: x != 13, my_list)) # A lambda function is just a short, one-line function that has no name. - -# We could also do - -def not_thirteen(x): - return x != 13 - -list(filter(not_thirteen, my_list)) - -# filter() passes each element of my_list as a parameter to the function. -# Pretty neat, eh? diff --git a/section2/12_decorators.py b/section2/12_decorators.py deleted file mode 100644 index 103b360c..00000000 --- a/section2/12_decorators.py +++ /dev/null @@ -1,55 +0,0 @@ -# A decorator is just a function that gets called before another function - -import functools # function tools - -def my_decorator(f): - @functools.wraps(f) - def function_that_runs_f(): - print("Hello!") - f() - print("After!") - return function_that_runs_f - -@my_decorator -def my_function(): - print("I'm in the function.") - -my_function() - - -### - -def my_decorator(f): - @functools.wraps(f) - def function_that_runs_f(*args, **kwargs): - print("Hello!") - f(*args, **kwargs) - print("After!") - return function_that_runs_f - -@my_decorator -def my_function(arg1, arg2): - print(arg1 + arg2) - -my_function(56, 89) - -### - -def decorator_arguments(number): - def my_decorator(f): - @functools.wraps(f) - def function_that_runs_f(*args, **kwargs): - print("Hello!") - if number == 56: - print("Not running!") - else: - f(*args, **kwargs) - print("After") - return function_that_runs_f - return my_decorator - -@decorator_arguments(56) -def my_function(): - print("Hello!") - -my_function() diff --git a/section2/1_variables_methods.py b/section2/1_variables_methods.py deleted file mode 100644 index 783abddb..00000000 --- a/section2/1_variables_methods.py +++ /dev/null @@ -1,27 +0,0 @@ -a = 5 -b = 10 -my_variable = 56 -any_variable_name = 100 - -string_variable = "hello" -single_quotes = 'strings can have single quotes' - -print(string_variable) -print(my_variable) - -# print is a method with one parameter—what we want to print - -def my_print_method(my_parameter): - print(my_parameter) - -my_print_method(string_variable) - -def my_multiplication_method(number_one, number_two): - return number_one * number_two - -result = my_multiplication_method(a, b) -print(result) - -print(my_multiplication_method(56, 75)) - -my_print_method(my_multiplication_method('b', 5)) # What would this do? diff --git a/section2/2_lists_tuples_sets.py b/section2/2_lists_tuples_sets.py deleted file mode 100644 index 9456df93..00000000 --- a/section2/2_lists_tuples_sets.py +++ /dev/null @@ -1,40 +0,0 @@ -my_variable = 'hello' -my_list_variable = ['hello', 'hi', 'nice to meet you'] -my_tuple_variable = ('hello', 'hi', 'nice to meet you') -my_set_variable = {'hello', 'hi', 'nice to meet you'} - -print(my_list_variable) -print(my_tuple_variable) -print(my_set_variable) - -my_short_tuple_variable = ("hello",) -another_short_tuple_variable = "hello", - -print(my_list_variable[0]) -print(my_tuple_variable[0]) -print(my_set_variable[0]) # This won't work, because there is no order. Which one is element 0? - -my_list_variable.append('another string') -print(my_list_variable) - -my_tuple_variable.append('a string') # This won't work, because a tuple is not a list. -my_tuple_variable = my_tuple_variable + ("a string",) -print(my_tuple_variable) -my_tuple_variable[0] = 'can I change this?' # No, you can't - -my_set_variable.add('hello') -print(my_set_variable) -my_set_variable.add('hello') -print(my_set_variable) - - -###### Set Operations - -set_one = {1, 2, 3, 4, 5} -set_two = {1, 3, 5, 7, 9, 11} - -print(set_one.intersection(set_two)) # {1, 3, 5} - -print({1, 2}.union({2, 3})) # {1, 2, 3} - -print({1, 2, 3, 4}.difference({2, 4})) # {1, 3} diff --git a/section2/3_loops.py b/section2/3_loops.py deleted file mode 100644 index 41c11126..00000000 --- a/section2/3_loops.py +++ /dev/null @@ -1,23 +0,0 @@ -my_string = "hello" - -for character in my_string: - print(character) - -for asdf in my_string: - print(asdf) - -my_list = [1, 2, 5, 3, 67] - -for number in my_list: - print(number) - -for number in my_list: - print(number ** 2) - -should_continue = True -while should_continue: - print("I'm continuing!") - - user_input = input("Should we continue? (y/n)") - if user_input == 'n': - should_continue = False diff --git a/section2/4_if_statements.py b/section2/4_if_statements.py deleted file mode 100644 index 4ddbd0f6..00000000 --- a/section2/4_if_statements.py +++ /dev/null @@ -1,31 +0,0 @@ -my_known_people = ["John", "Rolf", "Anne"] -user_name = input("Enter your name: ") -if user_name in my_known_people: - print("Hello, I know you!") - - -if user_name in my_known_people: - print("Hello {}, I know you!".format(user_name)) - - -if user_name in my_known_people: - print("Hello {name}, I know you!".format(name=user_name)) - -"Hello {name}, I know you {}!".format("well", name=user_name) -"Hello {}, I know you {}!".format("John", "well") - -#### Exercise - -def who_do_you_know(): - names = input("Enter the names of people you know, separated by commas: ") - names_list = names.split(",") - return names_list - -def ask_user(): - # Ask user for their name - # See if their name is in list of people - # Print something if it is - - user_name = input("Enter your name: ") - if user_name in who_do_you_know(): - print("Hello {}, I know you!".format(user_name)) diff --git a/section2/5_list_comprehension.py b/section2/5_list_comprehension.py deleted file mode 100644 index df1d7e77..00000000 --- a/section2/5_list_comprehension.py +++ /dev/null @@ -1,20 +0,0 @@ -my_list = [0, 1, 2, 3, 4] -an_equal_list = [x for x in range(5)] - -for my_number in range(10): - print(my_number) - -[my_number for my_number in range(10)] - -[my_number * 2 for my_number in range(10)] - -1 % 2 -2 % 2 -5 % 2 -8 % 3 - -[n for n in range(10) if n % 2 == 0] - -names_list = ["John", "Rolf", "Anne"] -lowercase_names = [name.lower() for name in names_list] -print(lowercase_names) diff --git a/section2/6_dictionaries.py b/section2/6_dictionaries.py deleted file mode 100644 index ce02a298..00000000 --- a/section2/6_dictionaries.py +++ /dev/null @@ -1,66 +0,0 @@ -my_dict = { - 'name': 'Jose', - 'location': 'UK' -} - -lottery_player = { - 'name': 'Rolf', - 'numbers': (13, 22, 3, 6, 9) -} - -dict_in_dict = { - 'universities': [ - { - 'name': 'Oxford', - 'location': 'UK' - }, - { - 'name': 'Harvard', - 'location': 'US' - } - ] -} - -## - -lottery_player = { - 'name': 'Rolf', - 'numbers': (13, 22, 3, 6, 9) -} - -players = [ - { - 'name': 'Rolf', - 'numbers': (13, 22, 3, 6, 9) - }, - { - 'name': 'John', - 'numbers': (22, 3, 5, 7, 9) - } -] - -# How could we select one of these? - -player = players[0] - -# How could we add all the numbers of a player? - -sum(player['numbers']) - -# We have a method that takes in a list—it does not have to be a list of numbers -# of a player. Indeed, we could do something like this: - -sum([1, 2, 3, 4, 5]) - -# Wouldn't it be nice if the player itself (the dictionary) had a method -# that would give us the sum of its numbers? Something like this: - -player.total() - -# If the player had a method that gives us the sum of its numbers, -# it makes it more difficult to "game" the system—we can no longer pass in -# a different list of numbers. - -# In addition, because what we are interested in is the sum of the players' numbers, -# it makes sense for the player itself to tell us that, and not some other method -# that is not a part of the player. diff --git a/section2/7_classes_objects.py b/section2/7_classes_objects.py deleted file mode 100644 index 1065e13b..00000000 --- a/section2/7_classes_objects.py +++ /dev/null @@ -1,66 +0,0 @@ -my_list_variable = [1, 2, 3] -print(my_list_variable) - -another_list = my_list_variable -another_list.append(4) -print(another_list) -print(my_list_variable) - -### - -final_list = [n for n in my_list_variable] -final_list.append(5) -print(final_list) -print(my_list_variable) - -### - -class Student: - def __init__(self): - self.name = "John" - self.school = "Harvard" - -my_student_variable = Student() -print(my_student_variable) -print(my_student_variable.name) -print(my_student_variable.school) - -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - -Student() -another_student = Student("Rolf", "MIT") -print(another_student) -print(another_student.name) -print(another_student.school) - -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - self.marks = [] - -anna = Student("Anna", "Oxford") -print(anna.marks) -anna.marks.append(56) -anna.marks.append(99) -print(anna.marks) - -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - self.marks = [] - - def average(self): - return sum(marks) / len(marks) - - -anna = Student("Anna", "Oxford") -print(anna.marks) -anna.marks.append(56) -anna.marks.append(99) -print(anna.marks) -print(anna.average()) diff --git a/section2/8_static_class_methods.py b/section2/8_static_class_methods.py deleted file mode 100644 index 76d45ea7..00000000 --- a/section2/8_static_class_methods.py +++ /dev/null @@ -1,80 +0,0 @@ - -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - self.marks = [] - - def average(self): - return sum(self.marks) / len(self.marks) - - def go_to_school(self): - return "I'm going to {}".format(self.school) - -anna = Student("Anna", "Oxford") -rolf = Student("Rolf", "Harvard") - -print(anna.go_to_school()) -print(rolf.go_to_school()) - -### - -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - self.marks = [] - - def average(self): - return sum(self.marks) / len(self.marks) - - def go_to_school(self): - return "I'm going to school" - -anna = Student("Anna", "Oxford") -rolf = Student("Rolf", "Harvard") - -print(anna.go_to_school()) -print(rolf.go_to_school()) - -### - -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - self.marks = [] - - def average(self): - return sum(self.marks) / len(self.marks) - - @staticmethod - def go_to_school(): - return "I'm going to school" - -anna = Student("Anna", "Oxford") -rolf = Student("Rolf", "Harvard") - -print(anna.go_to_school()) -print(rolf.go_to_school()) - -### - -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - self.marks = [] - - def average(self): - return sum(self.marks) / len(self.marks) - - def friend(self, friend_name): - return Student(friend_name, self.school) - -anna = Student("Anna", "Oxford") - -friend = anna.friend("Greg") -print(friend.name) -print(friend.school) - diff --git a/section2/9_inheritance.py b/section2/9_inheritance.py deleted file mode 100644 index bf37e2a0..00000000 --- a/section2/9_inheritance.py +++ /dev/null @@ -1,52 +0,0 @@ -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - self.marks = [] - - def average(self): - return sum(marks) / len(marks) - - def friend(self, friend_name): - return Student(friend_name, self.school) - -anna = Student("Anna", "Oxford") - -friend = anna.friend("Greg") -print(friend.name) -print(friend.school) - -### - -class WorkingStudent(Student): - def __init__(self, name, school, salary): - super().__init__(name, school) - self.salary = salary - -rolf = WorkingStudent("Rolf", "Harvard", 20.00) -sue = rolf.friend("Sue") -print(sue.salary) # Error! - -### - -class Student: - def __init__(self, name, school): - self.name = name - self.school = school - self.marks = [] - - def average(self): - return sum(marks) / len(marks) - - @classmethod - def friend(cls, origin, friend_name, *args): - return cls(friend_name, origin.school, *args) - -class WorkingStudent(Student): - def __init__(self, name, school, salary): - super().__init__(name, school) - self.salary = salary - -rolf = WorkingStudent("Rolf", "Harvard", 20.00) -sue = WorkingStudent.friend(rolf, "Sue", 15.00) -print(sue.salary) # This works! diff --git a/section3/app.py b/section3/app.py deleted file mode 100644 index a7c807ed..00000000 --- a/section3/app.py +++ /dev/null @@ -1,66 +0,0 @@ -from flask import Flask,jsonify,request,render_template - -app = Flask(__name__) - -stores = [{ - 'name': 'My Store', - 'items': [{'name':'my item', 'price': 15.99 }] -}] - -@app.route('/') -def home(): - return render_template('index.html') - -#post /store data: {name :} -@app.route('/store' , methods=['POST']) -def create_store(): - request_data = request.get_json() - new_store = { - 'name':request_data['name'], - 'items':[] - } - stores.append(new_store) - return jsonify(new_store) - #pass - -#get /store/ data: {name :} -@app.route('/store/') -def get_store(name): - for store in stores: - if store['name'] == name: - return jsonify(store) - return jsonify ({'message': 'store not found'}) - #pass - -#get /store -@app.route('/store') -def get_stores(): - return jsonify({'stores': stores}) - #pass - -#post /store/ data: {name :} -@app.route('/store//item' , methods=['POST']) -def create_item_in_store(name): - request_data = request.get_json() - for store in stores: - if store['name'] == name: - new_item = { - 'name': request_data['name'], - 'price': request_data['price'] - } - store['items'].append(new_item) - return jsonify(new_item) - return jsonify ({'message' :'store not found'}) - #pass - -#get /store//item data: {name :} -@app.route('/store//item') -def get_item_in_store(name): - for store in stores: - if store['name'] == name: - return jsonify( {'items':store['items'] } ) - return jsonify ({'message':'store not found'}) - - #pass - -app.run(port=5000) diff --git a/section3/templates/index.html b/section3/templates/index.html deleted file mode 100644 index c731d900..00000000 --- a/section3/templates/index.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - -
- Hello, world! -
- - - diff --git a/section4/app.py b/section4/app.py deleted file mode 100644 index 69ed7ed1..00000000 --- a/section4/app.py +++ /dev/null @@ -1,64 +0,0 @@ -from flask import Flask, request -from flask_restful import Resource, Api, reqparse -from flask_jwt import JWT, jwt_required, current_identity - -from security import authenticate, identity - -app = Flask(__name__) -app.config['PROPAGATE_EXCEPTIONS'] = True # To allow flask propagating exception even if debug is set to false on app -app.secret_key = 'jose' -api = Api(app) - -jwt = JWT(app, authenticate, identity) - -items = [] - -class Item(Resource): - parser = reqparse.RequestParser() - parser.add_argument('price', - type=float, - required=True, - help="This field cannot be left blank!" - ) - - @jwt_required() - def get(self, name): - return {'item': next(filter(lambda x: x['name'] == name, items), None)} - - def post(self, name): - if next(filter(lambda x: x['name'] == name, items), None) is not None: - return {'message': "An item with name '{}' already exists.".format(name)} - - data = Item.parser.parse_args() - - item = {'name': name, 'price': data['price']} - items.append(item) - return item - - @jwt_required() - def delete(self, name): - global items - items = list(filter(lambda x: x['name'] != name, items)) - return {'message': 'Item deleted'} - - @jwt_required() - def put(self, name): - data = Item.parser.parse_args() - # Once again, print something not in the args to verify everything works - item = next(filter(lambda x: x['name'] == name, items), None) - if item is None: - item = {'name': name, 'price': data['price']} - items.append(item) - else: - item.update(data) - return item - -class ItemList(Resource): - def get(self): - return {'items': items} - -api.add_resource(Item, '/item/') -api.add_resource(ItemList, '/items') - -if __name__ == '__main__': - app.run(debug=True) # important to mention debug=True diff --git a/section4/security.py b/section4/security.py deleted file mode 100644 index c43cd839..00000000 --- a/section4/security.py +++ /dev/null @@ -1,19 +0,0 @@ -from hmac import compare_digest -from user import User - -users = [ - User(1, 'user1', 'abcxyz'), - User(2, 'user2', 'abcxyz'), -] - -username_table = {u.username: u for u in users} -userid_table = {u.id: u for u in users} - -def authenticate(username, password): - user = username_table.get(username, None) - if user and compare_digest(user.password, password): - return user - -def identity(payload): - user_id = payload['identity'] - return userid_table.get(user_id, None) diff --git a/section4/user.py b/section4/user.py deleted file mode 100644 index 7ff669ba..00000000 --- a/section4/user.py +++ /dev/null @@ -1,5 +0,0 @@ -class User(object): - def __init__(self, id, username, password): - self.id = id - self.username = username - self.password = password diff --git a/section5/app.py b/section5/app.py deleted file mode 100644 index ee844611..00000000 --- a/section5/app.py +++ /dev/null @@ -1,21 +0,0 @@ -from flask import Flask -from flask_restful import Api -from flask_jwt import JWT - -from security import authenticate, identity -from user import UserRegister -from item import Item, ItemList - -app = Flask(__name__) -app.config['PROPAGATE_EXCEPTIONS'] = True -app.secret_key = 'jose' -api = Api(app) - -jwt = JWT(app, authenticate, identity) - -api.add_resource(Item, '/item/') -api.add_resource(ItemList, '/items') -api.add_resource(UserRegister, '/register') - -if __name__ == '__main__': - app.run(debug=True) # important to mention debug=True diff --git a/section5/create_table.py b/section5/create_table.py deleted file mode 100644 index d546854e..00000000 --- a/section5/create_table.py +++ /dev/null @@ -1,17 +0,0 @@ -import sqlite3 - -connection = sqlite3.connect('data.db') - -cursor = connection.cursor() - -# MUST BE INTEGER -# This is the only place where int vs INTEGER matters—in auto-incrementing columns -create_table = "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username text, password text)" -cursor.execute(create_table) - -create_table = "CREATE TABLE IF NOT EXISTS items (name text PRIMARY KEY, price real)" -cursor.execute(create_table) - -connection.commit() - -connection.close() diff --git a/section5/item.py b/section5/item.py deleted file mode 100644 index b5a2010a..00000000 --- a/section5/item.py +++ /dev/null @@ -1,118 +0,0 @@ -from flask_restful import Resource, reqparse -from flask_jwt import jwt_required -import sqlite3 - - -class Item(Resource): - TABLE_NAME = 'items' - - parser = reqparse.RequestParser() - parser.add_argument('price', - type=float, - required=True, - help="This field cannot be left blank!" - ) - - @jwt_required() - def get(self, name): - item = self.find_by_name(name) - if item: - return item - return {'message': 'Item not found'}, 404 - - @classmethod - def find_by_name(cls, name): - connection = sqlite3.connect('data.db') - cursor = connection.cursor() - - query = "SELECT * FROM {table} WHERE name=?".format(table=cls.TABLE_NAME) - result = cursor.execute(query, (name,)) - row = result.fetchone() - connection.close() - - if row: - return {'item': {'name': row[0], 'price': row[1]}} - - def post(self, name): - if self.find_by_name(name): - return {'message': "An item with name '{}' already exists.".format(name)} - - data = Item.parser.parse_args() - - item = {'name': name, 'price': data['price']} - - try: - Item.insert(item) - except: - return {"message": "An error occurred inserting the item."} - - return item - - @classmethod - def insert(cls, item): - connection = sqlite3.connect('data.db') - cursor = connection.cursor() - - query = "INSERT INTO {table} VALUES(?, ?)".format(table=cls.TABLE_NAME) - cursor.execute(query, (item['name'], item['price'])) - - connection.commit() - connection.close() - - @jwt_required() - def delete(self, name): - connection = sqlite3.connect('data.db') - cursor = connection.cursor() - - query = "DELETE FROM {table} WHERE name=?".format(table=self.TABLE_NAME) - cursor.execute(query, (name,)) - - connection.commit() - connection.close() - - return {'message': 'Item deleted'} - - @jwt_required() - def put(self, name): - data = Item.parser.parse_args() - item = self.find_by_name(name) - updated_item = {'name': name, 'price': data['price']} - if item is None: - try: - Item.insert(updated_item) - except: - return {"message": "An error occurred inserting the item."} - else: - try: - Item.update(updated_item) - except: - return {"message": "An error occurred updating the item."} - return updated_item - - @classmethod - def update(cls, item): - connection = sqlite3.connect('data.db') - cursor = connection.cursor() - - query = "UPDATE {table} SET price=? WHERE name=?".format(table=cls.TABLE_NAME) - cursor.execute(query, (item['price'], item['name'])) - - connection.commit() - connection.close() - - -class ItemList(Resource): - TABLE_NAME = 'items' - - def get(self): - connection = sqlite3.connect('data.db') - cursor = connection.cursor() - - query = "SELECT * FROM {table}".format(table=self.TABLE_NAME) - result = cursor.execute(query) - items = [] - for row in result: - items.append({'name': row[0], 'price': row[1]}) - connection.close() - - return {'items': items} diff --git a/section5/security.py b/section5/security.py deleted file mode 100644 index ee6cc423..00000000 --- a/section5/security.py +++ /dev/null @@ -1,13 +0,0 @@ -from hmac import compare_digest -from user import User - - -def authenticate(username, password): - user = User.find_by_username(username) - if user and compare_digest(user.password, password): - return user - - -def identity(payload): - user_id = payload['identity'] - return User.find_by_id(user_id) diff --git a/section5/user.py b/section5/user.py deleted file mode 100644 index 40ffc9e4..00000000 --- a/section5/user.py +++ /dev/null @@ -1,76 +0,0 @@ -import sqlite3 -from flask_restful import Resource, reqparse - - -class User(): - TABLE_NAME = 'users' - - def __init__(self, _id, username, password): - self.id = _id - self.username = username - self.password = password - - @classmethod - def find_by_username(cls, username): - connection = sqlite3.connect('data.db') - cursor = connection.cursor() - - query = "SELECT * FROM {table} WHERE username=?".format(table=cls.TABLE_NAME) - result = cursor.execute(query, (username,)) - row = result.fetchone() - if row: - user = cls(*row) - else: - user = None - - connection.close() - return user - - @classmethod - def find_by_id(cls, _id): - connection = sqlite3.connect('data.db') - cursor = connection.cursor() - - query = "SELECT * FROM {table} WHERE id=?".format(table=cls.TABLE_NAME) - result = cursor.execute(query, (_id,)) - row = result.fetchone() - if row: - user = cls(*row) - else: - user = None - - connection.close() - return user - - -class UserRegister(Resource): - TABLE_NAME = 'users' - - parser = reqparse.RequestParser() - parser.add_argument('username', - type=str, - required=True, - help="This field cannot be left blank!" - ) - parser.add_argument('password', - type=str, - required=True, - help="This field cannot be left blank!" - ) - - def post(self): - data = UserRegister.parser.parse_args() - - if User.find_by_username(data['username']): - return {"message": "User with that username already exists."}, 400 - - connection = sqlite3.connect('data.db') - cursor = connection.cursor() - - query = "INSERT INTO {table} VALUES (NULL, ?, ?)".format(table=self.TABLE_NAME) - cursor.execute(query, (data['username'], data['password'])) - - connection.commit() - connection.close() - - return {"message": "User created successfully."}, 201 diff --git a/section6/app.py b/section6/app.py deleted file mode 100644 index df507f1e..00000000 --- a/section6/app.py +++ /dev/null @@ -1,34 +0,0 @@ -from flask import Flask -from flask_restful import Api -from flask_jwt import JWT - -from security import authenticate, identity -from resources.user import UserRegister -from resources.item import Item, ItemList -from resources.store import Store, StoreList - -app = Flask(__name__) -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///data.db' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -app.config['PROPAGATE_EXCEPTIONS'] = True -app.secret_key = 'jose' -api = Api(app) - - -@app.before_first_request -def create_tables(): - db.create_all() - - -jwt = JWT(app, authenticate, identity) # /auth - -api.add_resource(Store, '/store/') -api.add_resource(StoreList, '/stores') -api.add_resource(Item, '/item/') -api.add_resource(ItemList, '/items') -api.add_resource(UserRegister, '/register') - -if __name__ == '__main__': - from db import db - db.init_app(app) - app.run(port=5000, debug=True) diff --git a/section6/models/item.py b/section6/models/item.py deleted file mode 100644 index 3e2acf41..00000000 --- a/section6/models/item.py +++ /dev/null @@ -1,32 +0,0 @@ -from db import db - - -class ItemModel(db.Model): - __tablename__ = 'items' - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(80)) - price = db.Column(db.Float(precision=2)) - - store_id = db.Column(db.Integer, db.ForeignKey('stores.id')) - store = db.relationship('StoreModel') - - def __init__(self, name, price, store_id): - self.name = name - self.price = price - self.store_id = store_id - - def json(self): - return {'name': self.name, 'price': self.price} - - @classmethod - def find_by_name(cls, name): - return cls.query.filter_by(name=name).first() - - def save_to_db(self): - db.session.add(self) - db.session.commit() - - def delete_from_db(self): - db.session.delete(self) - db.session.commit() diff --git a/section6/resources/item.py b/section6/resources/item.py deleted file mode 100644 index 98fad95e..00000000 --- a/section6/resources/item.py +++ /dev/null @@ -1,65 +0,0 @@ -from flask_restful import Resource, reqparse -from flask_jwt import jwt_required -from models.item import ItemModel - - -class Item(Resource): - parser = reqparse.RequestParser() - parser.add_argument('price', - type=float, - required=True, - help="This field cannot be left blank!" - ) - parser.add_argument('store_id', - type=int, - required=True, - help="Every item needs a store_id." - ) - - @jwt_required() - def get(self, name): - item = ItemModel.find_by_name(name) - if item: - return item.json() - return {'message': 'Item not found'}, 404 - - def post(self, name): - if ItemModel.find_by_name(name): - return {'message': "An item with name '{}' already exists.".format(name)}, 400 - - data = Item.parser.parse_args() - - item = ItemModel(name, **data) - - try: - item.save_to_db() - except: - return {"message": "An error occurred inserting the item."}, 500 - - return item.json(), 201 - - def delete(self, name): - item = ItemModel.find_by_name(name) - if item: - item.delete_from_db() - return {'message': 'Item deleted.'} - return {'message': 'Item not found.'}, 404 - - def put(self, name): - data = Item.parser.parse_args() - - item = ItemModel.find_by_name(name) - - if item: - item.price = data['price'] - else: - item = ItemModel(name, **data) - - item.save_to_db() - - return item.json() - - -class ItemList(Resource): - def get(self): - return {'items': list(map(lambda x: x.json(), ItemModel.query.all()))} diff --git a/section6/resources/store.py b/section6/resources/store.py deleted file mode 100644 index cff57420..00000000 --- a/section6/resources/store.py +++ /dev/null @@ -1,34 +0,0 @@ -from flask_restful import Resource -from models.store import StoreModel - - -class Store(Resource): - def get(self, name): - store = StoreModel.find_by_name(name) - if store: - return store.json() - return {'message': 'Store not found'}, 404 - - def post(self, name): - if StoreModel.find_by_name(name): - return {'message': "A store with name '{}' already exists.".format(name)}, 400 - - store = StoreModel(name) - try: - store.save_to_db() - except: - return {"message": "An error occurred creating the store."}, 500 - - return store.json(), 201 - - def delete(self, name): - store = StoreModel.find_by_name(name) - if store: - store.delete_from_db() - - return {'message': 'Store deleted'} - - -class StoreList(Resource): - def get(self): - return {'stores': list(map(lambda x: x.json(), StoreModel.query.all()))} diff --git a/section6/resources/user.py b/section6/resources/user.py deleted file mode 100644 index 759e0030..00000000 --- a/section6/resources/user.py +++ /dev/null @@ -1,27 +0,0 @@ -from flask_restful import Resource, reqparse -from models.user import UserModel - - -class UserRegister(Resource): - parser = reqparse.RequestParser() - parser.add_argument('username', - type=str, - required=True, - help="This field cannot be blank." - ) - parser.add_argument('password', - type=str, - required=True, - help="This field cannot be blank." - ) - - def post(self): - data = UserRegister.parser.parse_args() - - if UserModel.find_by_username(data['username']): - return {"message": "A user with that username already exists"}, 400 - - user = UserModel(data['username'], data['password']) - user.save_to_db() - - return {"message": "User created successfully."}, 201 diff --git a/section6/security.py b/section6/security.py deleted file mode 100644 index 3d550660..00000000 --- a/section6/security.py +++ /dev/null @@ -1,13 +0,0 @@ -from hmac import compare_digest -from models.user import UserModel - - -def authenticate(username, password): - user = UserModel.find_by_username(username) - if user and compare_digest(user.password, password): - return user - - -def identity(payload): - user_id = payload['identity'] - return UserModel.find_by_id(user_id) diff --git a/section9/lectures/135_want_to_deploy_to_aws/commands.md b/section9/lectures/135_want_to_deploy_to_aws/commands.md deleted file mode 100644 index 464c2d9a..00000000 --- a/section9/lectures/135_want_to_deploy_to_aws/commands.md +++ /dev/null @@ -1,7 +0,0 @@ -## Deploy to AWS -If deploying on AWS, after installing Ubuntu 16.04, make sure to install libpcre3 and libpcre3-dev by running the following commands: - -```bash -sudo apt-get install libpcre3 libpcre3-dev -pip install uwsgi -I --no-cache-dir -``` diff --git a/section9/lectures/136_installing_postgreSQL_in_ubuntu_16/commands.md b/section9/lectures/136_installing_postgreSQL_in_ubuntu_16/commands.md deleted file mode 100644 index 2ad02051..00000000 --- a/section9/lectures/136_installing_postgreSQL_in_ubuntu_16/commands.md +++ /dev/null @@ -1,30 +0,0 @@ -## Install PostreSQL on Ubuntu 16.04 - -While logged in as root user: - -```bash -apt-get update -apt-get install postgresql postgresql-contrib -``` - -After installation, change user to postgres user by running -```bash -sudo -i -u postgres -``` - -Connect to the database by running - -```bash -psql -``` - -To exit postgres database console, run - -```bash -\q -``` - -To exit postgres user -```bash -exit -``` diff --git a/section9/lectures/137_creating_a_unix_user_in_ubuntu_16/commands.md b/section9/lectures/137_creating_a_unix_user_in_ubuntu_16/commands.md deleted file mode 100644 index e7e103c2..00000000 --- a/section9/lectures/137_creating_a_unix_user_in_ubuntu_16/commands.md +++ /dev/null @@ -1,57 +0,0 @@ -## Create a user in Ubuntu 16.04 -To create a new user, run the following command and enter the user details as prompted (password, full name etc). Remember to replace "jose" with the name of your user. - - -```bash -adduser jose -``` - -### Add the new user to sudo users - -Running the visudo command will open a file (normally located at /etc/sudoers). -```bash -visudo -``` - -Under "User privilege specification", add the following line below root line - -```bash -jose ALL=(ALL:ALL) ALL -``` - -Save the file with CNTR+O and press enter to save it. Then CNTR+X to exit the file. - -### To enable logging in to the server as the newly created user, enable password login to the server by running - -```bash -vi /etc/ssh/sshd_config -``` - -This opens another file. To disable root login with password, change the following line. Note you need to press "i" key to go to insert/edit mode before changing the contents of the file. - -```bash -PermitRootLogin yes -``` - -to - -```bash -PermitRootLogin no -``` - -Scroll down to the end of the file and add the following line - -```bash -AllowUsers jose -``` - - -To exit edit/insert mode, press escape key then ":" followed by wq and press enter. "wq" writes the file to disc and quits the file. - -Finally, run - -```bash -service sshd reload -``` - - diff --git a/section9/lectures/138_setting_up_our_new_user_with_postgreSQL_permissions/commands.md b/section9/lectures/138_setting_up_our_new_user_with_postgreSQL_permissions/commands.md deleted file mode 100644 index 488b85ce..00000000 --- a/section9/lectures/138_setting_up_our_new_user_with_postgreSQL_permissions/commands.md +++ /dev/null @@ -1,37 +0,0 @@ -## Set up New User with PostgreSQL Permissions -This section assumes you created a new unix user in Ubuntu 16.04 (instructions in the previous lecture) and you are logged into the server as the new user. - -To become the postgres user, run - -```bash -sudo su -sudo -i -u postgres -``` - -To create a postgres user, run the following command. Note, the user must have the same name as the unix user logged into the server ("jose" in our case). - -```bash -createuser jose -P -``` - -You will prompted twice to set the password for the new postgres user. - -To create a database, run - -```bash -createdb jose -``` - -To enforce password login to PostgreSQL with user jose, run the following commands. Note 9.5 is the PostgreSQL version installed in your server. Later this version may change so make sure to change your accordingly. - -```bash -vi /etc/postgresql/9.5/main/pg_hba.conf -``` - -Scroll down to the bottom and change "peer" to "md5" in the following line under '# "local" is for unix domain socket connections only comment'. This is how the line should look after changing. - -```bash -local all all md5 -``` - -Finally, write and quit. diff --git a/section9/lectures/139_setting_up_nginx_and_our_rest_api/commands.md b/section9/lectures/139_setting_up_nginx_and_our_rest_api/commands.md deleted file mode 100644 index 7cebd92e..00000000 --- a/section9/lectures/139_setting_up_nginx_and_our_rest_api/commands.md +++ /dev/null @@ -1,117 +0,0 @@ -## Setting up Nginx -To install and cofigure Nginx on your server, follow the following instructions. - -First, you need to update your server by running - -```bash -sudo apt-get update -``` - -To install Nginx, run - -```bash -sudo apt-get install nginx -``` - -Allow Nginx access through firewall (otherwise incoming requests will be blocked by the firewall) - -```bash -sudo ufw enable -sudo ufw allow 'Nginx HTTP' -``` - -Also since we have enabled firewall, remember to allow ssh through the firewall, else you will get locked out of the server. - -```bash -sudo ufw allow ssh -``` - -You can check firewall status by using - -```bash -sudo ufw status -``` - -To check Nginx status, use - -```bash -systemctl status nginx -``` - -The following commands stop, start and restart Nginx respectively. - -```bash -systemctl stop nginx -systemctl start nginx -systemctl restart nginx -``` - -### Configuring Nginx -Create a new file for the items REST API configuration. - -```bash -sudo vi /etc/nginx/sites-available/items-rest.conf -``` - -Press "i" key (insert mode), copy and paste the following to the file - -```bash -server { - listen 80; - real_ip_header X-Forwarded-For; - set_real_ip_from 127.0.0.1; - server_name localhost; - - location / { - include uwsgi_params; - uwsgi_pass unix:/var/www/html/items-rest/socket.sock; - uwsgi_modifier1 30; - } - - error_page 404 /404.html; - location = 404.html { - root /usr/share/nginx/html; - } - - error_page 500 502 503 504 50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } -} -``` - -After writing and quiting the file (escape key then :wq enter) enable the configuration by running - -```bash -sudo ln -s /etc/nginx/sites-available/items-rest.conf /etc/nginx/sites-enabled/ -``` - -### Create the socket.sock file and clone the items rest app -Create a directory/folder for the app - -```bash -sudo mkdir /var/www/html/items-rest -``` - -Since the director was created with root user, give access to the unix user ("jose" in our case) by making the user the owner of the directory. - -```bash -sudo chown jose:jose /var/www/html/items-rest -``` - -Got to the directory, clone the app and install dependencies. Run the following commands one by one in that order. - -```bash -cd /var/www/html/items-rest -git clone https://github.com/schoolofcode-me/stores-rest-api.git . -mkdir log -sudo apt-get install python-pip python3-dev libpq-dev -pip install virtualenv -virtualenv venv --python=python3.5 -source venv/bin/activate -pip install -r requirements.txt -``` - - - - diff --git a/section9/lectures/140_setting_up_uWSGI_to_run_our_REST_API/commands.md b/section9/lectures/140_setting_up_uWSGI_to_run_our_REST_API/commands.md deleted file mode 100644 index 95bdc161..00000000 --- a/section9/lectures/140_setting_up_uWSGI_to_run_our_REST_API/commands.md +++ /dev/null @@ -1,63 +0,0 @@ -## Setting up uWSGI -This guide will help you set up uWSGI on your server to run the items rest app. Go to the items-rest directory we created in the previous lecture. - -```bash -cd /var/www/html/items-rest -``` - -### Create Ubuntu service -Run the following command to create a service file. - -```bash -sudo vi /etc/systemd/system/uwsgi_items_rest.service -``` - -Copy and paste the following to the file. Note "jose:1234" is the username:password combination of the Postgres user we created before. Change yours accordingly. - -```bash -[Unit] -Description=uWSGI items rest - -[Service] -Environment=DATABASE_URL=postgres://jose:1234@localhost:5432/jose -ExecStart=/var/www/html/items-rest/venv/bin/uwsgi --master --emperor /var/www/html/items-rest/uwsgi.ini --die-on-term --uid jose --gid jose --logto /var/www/html/items-rest/log/emperor.log -Restart=always -KillSignal=SIGQUIT -Type=notify -NotifyAccess=all - -[Install] -WantedBy=multi-user.target -``` - -Replace the uwsgi.ini file contents with the following - -```bash -[uwsgi] -base = /var/www/html/items-rest -app = run -module = %(app) - -home = %(base)/venv -pythonpath = %(base) - -socket = %(base)/socket.sock - -chmod-socket = 777 - -processes = 8 - -threads = 8 - -harakiri = 15 - -callable = app - -logto = /var/www/html/items-rest/log/%n.log -``` - -Finally start the app by running - -```bash -sudo systemctl start uwsgi_items_rest -```