├── .dockerignore ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build.yaml │ ├── e2e.yml │ ├── format.yaml │ └── test.yaml ├── .gitignore ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DESIGN.md ├── LICENSE ├── Makefile ├── README.md ├── api ├── api.swagger.yaml └── tasks │ └── v1 │ ├── tasks.pb.go │ ├── tasks.pb.gw.go │ ├── tasks.proto │ ├── tasks_grpc.pb.go │ ├── tasks_pb.d.ts │ └── tasks_pb.js ├── buf.gen.yaml ├── buf.lock ├── buf.yaml ├── build ├── app.Dockerfile ├── ci │ └── dagger │ │ └── main.go └── gateway.Dockerfile ├── cmd ├── app │ └── main.go └── gateway │ └── main.go ├── configs ├── grafana │ └── datasource.yml ├── otel_collector │ └── config.yaml └── prometheus │ └── prometheus.yml ├── deployments ├── kubernetes │ ├── config-map.yaml │ ├── deployment.yaml │ ├── namespace.yaml │ └── service.yaml └── local │ ├── .env.ci │ ├── ci.docker-compose.yml │ └── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── conf │ ├── config.go │ └── config_test.go ├── domain │ ├── migration.go │ └── task.go ├── gateway │ ├── gateway.go │ ├── runner.go │ └── setup.go ├── interceptors │ ├── client.go │ ├── common.go │ └── server.go ├── serializer │ ├── page_token.go │ ├── page_token_test.go │ └── serializer.go ├── server │ ├── application.go │ ├── runner.go │ ├── server_test.go │ └── setup.go ├── service │ ├── tasks.go │ └── tasks_test.go └── telemetry │ ├── common.go │ ├── logging.go │ ├── metrics.go │ ├── telemetry.go │ └── tracing.go ├── package-lock.json ├── package.json ├── playwright.config.ts ├── tests ├── tasks.spec.ts └── tasks.utils.ts └── ui ├── .editorconfig ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── README.md ├── angular.json ├── eslint.config.js ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── server.ts ├── src ├── api │ └── tasks │ │ └── v1 │ │ ├── tasks_pb.d.ts │ │ └── tasks_pb.js ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.config.server.ts │ ├── app.config.ts │ ├── app.routes.ts │ ├── components │ │ └── icon-button-star │ │ │ ├── icon-button-star.component.html │ │ │ ├── icon-button-star.component.scss │ │ │ ├── icon-button-star.component.spec.ts │ │ │ └── icon-button-star.component.ts │ ├── layouts │ │ └── main-layout │ │ │ ├── main-layout.component.html │ │ │ ├── main-layout.component.scss │ │ │ ├── main-layout.component.spec.ts │ │ │ └── main-layout.component.ts │ ├── tasks │ │ ├── task-details │ │ │ ├── task-details.component.html │ │ │ ├── task-details.component.scss │ │ │ ├── task-details.component.spec.ts │ │ │ └── task-details.component.ts │ │ ├── task-list-item │ │ │ ├── task-list-item.component.html │ │ │ ├── task-list-item.component.scss │ │ │ ├── task-list-item.component.spec.ts │ │ │ └── task-list-item.component.ts │ │ ├── task-list │ │ │ ├── task-list.component.html │ │ │ ├── task-list.component.scss │ │ │ ├── task-list.component.spec.ts │ │ │ └── task-list.component.ts │ │ ├── task.service.spec.ts │ │ └── task.service.ts │ └── views │ │ ├── home-view │ │ ├── home-view.component.html │ │ ├── home-view.component.scss │ │ ├── home-view.component.spec.ts │ │ └── home-view.component.ts │ │ └── task-view │ │ ├── task-view.component.html │ │ ├── task-view.component.scss │ │ ├── task-view.component.spec.ts │ │ └── task-view.component.ts ├── environments │ ├── environment.development.ts │ └── environment.ts ├── index.html ├── main.server.ts ├── main.ts └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | coverage.html 17 | *.tx 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | # Go workspace file 23 | go.work 24 | 25 | # IDEs 26 | .idea/ 27 | 28 | # Environment variables 29 | .env.example 30 | .env 31 | 32 | # Git folder 33 | .git 34 | 35 | # Buf files 36 | buf.gen.yaml 37 | buf.lock 38 | buf.yaml 39 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Application configuration 2 | ENVIRONMENT="staging" 3 | APPLICATION_NAME="todo" 4 | APPLICATION_PORT=3030 5 | 6 | # Gateway configuration 7 | SERVER_ADDRESS="todo:3030" 8 | 9 | # Database configuration 10 | DATABASE_ENGINE="mysql" 11 | DATABASE_NAME="todo" 12 | DATABASE_HOST="localhost" 13 | DATABASE_USER="root" 14 | DATABASE_PASSWORD="changeme" 15 | DATABASE_PORT=3306 16 | DATABASE_CHARSET="utf8mb4" 17 | 18 | # MySQL configuration 19 | MYSQL_DATABASE="todo" 20 | MYSQL_ROOT_PASSWORD="changeme" 21 | 22 | # Telemetry 23 | ## Tracing 24 | TRACING_ENABLED="false" 25 | TRACING_HOST="localhost" 26 | TRACING_PORT="4317" 27 | 28 | ## Metrics 29 | METRICS_ENABLED="false" 30 | METRICS_HOST="localhost" 31 | METRICS_PORT="4317" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | commit-message: 8 | prefix: ":seedling:" 9 | open-pull-requests-limit: 3 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "daily" 14 | commit-message: 15 | prefix: ":seedling:" 16 | - package-ecosystem: docker 17 | directory: "/" 18 | schedule: 19 | interval: daily 20 | commit-message: 21 | prefix: ":whale:" 22 | open-pull-requests-limit: 3 -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build and push 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | name: [ app, gateway ] 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Metadata 21 | id: meta 22 | uses: docker/metadata-action@v5 23 | with: 24 | images: "ghcr.io/marcoshuck/todo/${{ matrix.name }}" 25 | tags: | 26 | type=semver,pattern={{version}} 27 | type=semver,pattern={{major}}.{{minor}} 28 | type=semver,pattern={{major}} 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | 33 | - name: Login to GitHub Container Registry 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.repository_owner }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Build and push 41 | uses: docker/build-push-action@v6 42 | with: 43 | push: true 44 | context: . 45 | file: "./build/${{ matrix.name }}.Dockerfile" 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | push: 7 | tags: 8 | - v* 9 | jobs: 10 | e2e: 11 | timeout-minutes: 10 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Start containers 17 | run: make ci/compose-up 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 18 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Install E2E Browsers 27 | run: npx playwright install --with-deps 28 | 29 | - name: Run E2E tests 30 | run: npx playwright test 31 | 32 | - name: Stop containers 33 | if: always() 34 | run: make ci/compose-down 35 | 36 | - uses: actions/upload-artifact@v4 37 | if: always() 38 | with: 39 | name: report 40 | path: playwright-report/ 41 | retention-days: 10 42 | -------------------------------------------------------------------------------- /.github/workflows/format.yaml: -------------------------------------------------------------------------------- 1 | name: Format 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | fmt: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | 20 | - name: fmt 21 | run: make fmt 22 | 23 | lint: 24 | runs-on: ubuntu-latest 25 | env: 26 | GOPRIVATE: ${{ inputs.go-private }} 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Set up Go 32 | uses: actions/setup-go@v5 33 | with: 34 | cache: true 35 | 36 | - name: Lint 37 | uses: golangci/golangci-lint-action@v6 38 | with: 39 | version: latest 40 | args: --timeout=3m 41 | 42 | vet: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | 48 | - name: Set up Go 49 | uses: actions/setup-go@v5 50 | with: 51 | go-version: '1.21' 52 | cache: true 53 | 54 | - name: vet 55 | run: make vet -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Go Test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: '1.21' 22 | cache: true 23 | 24 | - name: Test 25 | run: make test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | coverage.html 17 | *.tx 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | # Go workspace file 23 | go.work 24 | 25 | # IDEs 26 | .idea/ 27 | 28 | # Tests 29 | node_modules/ 30 | /test-results/ 31 | /playwright-report/ 32 | /blob-report/ 33 | /playwright/.cache/ 34 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @marcoshuck -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | marcos@huck.com.ar. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | We welcome contributions to our TODO Application project! Please take a moment to review these guidelines to ensure a smooth and collaborative contribution process. 4 | 5 | ## Feature Discussion 6 | 7 | 1. **Discuss Features**: Before starting work on a new feature, please initiate a discussion. This discussion should take place in the project's **Issues** section, where you can create an issue to propose the new feature or changes. 8 | 9 | 2. **Issue Creation**: Every open **Pull Request** (PR) should correspond to a matching **issue**, unless there are special circumstances. **Issues** help in tracking and documenting the purpose and progress of the PR. 10 | 11 | ## Pull Request Guidelines 12 | 13 | 3. **Code Owners Approval**: To ensure the quality of the codebase,** at least one code owner** must **approve** a **Pull Request (PR)** before it can be **merged**. Code owners are responsible for maintaining code quality and providing guidance to contributors. 14 | 15 | 4. **Coding Standards**: Follow the coding standards and best practices established in the project. Ensure that your code is clean, readable, and well-documented. 16 | 17 | 5. **Testing**: Write appropriate tests for your code changes. Ensure that existing tests pass, and add new tests when necessary to cover new features or changes. 18 | 19 | 6. **Documentation**: Update the project's documentation if your changes affect the project's functionality or usage. 20 | 21 | 7. **Commit Messages**: Use descriptive and concise commit messages. Each commit should have a clear and meaningful purpose. Reference the related issue in your commit message if applicable. 22 | 23 | 8. **Review Feedback**: Be open to feedback and comments on your PR. Address review comments promptly and make necessary adjustments. 24 | 25 | ## Feature Branches 26 | 27 | 9. **Feature Branches**: For each feature or bug fix, create a new branch based on the main branch. Use a clear and descriptive name for your feature branch. 28 | 29 | 10. **Version Tagging**: To track project versions, we create Git tags for significant releases. 30 | 31 | ## Pull Request Workflow 32 | 33 | 11. **Fork the Repository**: If you haven't already, fork the project's repository to your own GitHub account. 34 | 35 | 12. **Create a Branch**: Create a new branch for your changes from the main branch of the project. 36 | 37 | 13. **Submit a Pull Request**: When your work is complete, submit a Pull Request. Ensure that the PR title and description are clear and related to the corresponding issue. 38 | 39 | 14. **Review and Approval**: Wait for code owners and maintainers to review your PR. Make revisions as necessary based on their feedback. 40 | 41 | 15. **Merging**: After receiving an approval from at least one code owner and addressing review feedback, your PR will be merged. 42 | 43 | 16. **Thank You**: Your contribution is greatly appreciated! Thank you for helping improve the project. 44 | 45 | By following these guidelines, we can maintain a high standard of code quality and ensure that everyone's contributions are valued and integrated effectively. 46 | 47 | Additionally, create Git tags for versioning and use the Releases tab on GitHub to document significant releases. 48 | 49 | If you have any questions or need assistance, feel free to reach out to the project maintainers. 50 | 51 | Happy contributing! 52 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | # ToDo Application - Design document 2 | 3 | ## Introduction 4 | 5 | This document contains the technical documentation for the ToDo application. This document provides an overview of the 6 | architectural choices and design principles followed throughout the development of this project. 7 | 8 | ## Motivation 9 | 10 | This repository wasn't born from a training exercise, but rather from a desire to consolidate years of experience into a practical resource for the community. Witnessing the learning curve individuals face when exploring new technologies like gRPC and navigating intricate setups, I sought to create a comprehensive, single source of knowledge. 11 | 12 | ## API specification 13 | 14 | This API adheres to the [Google API Improvement Proposals (AIP)](https://google.aip.dev) guidelines, ensuring a 15 | standardized and efficient design. 16 | Below is a table outlining the various endpoints for CRUD operations within the application. 17 | 18 | | Endpoint | Service | Method | AIP | 19 | |---------------------------|--------------------|--------------|---------------------------------------| 20 | | GET /v1/tasks | TasksReaderService | GetTask | [AIP-131](https://google.aip.dev/131) | 21 | | GET /v1/tasks/{id} | TasksReaderService | ListTasks | [AIP-132](https://google.aip.dev/132) | 22 | | POST /v1/tasks | TasksWriterService | CreateTask | [AIP-133](https://google.aip.dev/133) | 23 | | PATCH /v1/tasks/{task.id} | TasksWriterService | UpdateTask | [AIP-134](https://google.aip.dev/134) | 24 | | DELETE /v1/tasks/{id} | TasksWriterService | DeleteTask | [AIP-135](https://google.aip.dev/135) | 25 | | POST /v1/tasks:undelete | TasksWriterService | UndeleteTask | [AIP-164](https://google.aip.dev/164) | 26 | 27 | Further readings: 28 | 29 | - [OpenAPI reference](api/api.swagger.yaml) 30 | - [Google API Improvement Proposals (AIP)](https://google.aip.dev) 31 | 32 | ### Code generation 33 | 34 | This project is using [Protocol buffers](https://protobuf.dev/) as single source of truth. Proto files can be found under the `api/` folder. 35 | 36 | In order to avoid repeating code, this project uses a tool called [Buf](https://buf.build/), which allows to generate stubs for different languages: `Go`, `TypeScript` and `Dart` are generated as example and also included in the source code. You can check the [buf.yaml](buf.yaml) and [buf.gen.yaml](buf.gen.yaml) configuration to learn how the different Buf plugins are configured to generate code for this project. 37 | 38 | ## Domain 39 | 40 | The domain of the ToDo application currently has a single entity called Task. Tasks group a definition of what needs to 41 | be done by an API consumer. 42 | 43 | In future iterations, this project could extend its domain to contemplate Users, Projects and more. 44 | 45 | ## Infrastructure 46 | 47 | ### Running the application 48 | 49 | The ToDo Application can be run either locally by using [docker-compose](deployments/local), or be deployed to 50 | a [Kubernetes](deployments/kubernetes) cluster. 51 | 52 | #### Kubernetes 53 | 54 | The kubernetes set up is rather simple, and it can be found under the `deployments/kubernetes` folder: 55 | 56 | ```mermaid 57 | flowchart LR 58 | ingress[Ingress] --> svc[Service/ClusterIP] 59 | svc --> deploy-gw[Depoyment/Gateway] 60 | deploy-gw --> deploy-app[Deployment/gRPC server] 61 | ``` 62 | 63 | NOTE: The ingress was not included due to the many possibilities that can be used for exposing the actual service to the internet. 64 | 65 | ### Persistence 66 | 67 | This application relies on a database engine that can be configured using the `DATABASE_` environment variables found in 68 | the [.env.example](.env.example) file. This would require setting up a MySQL server in production. The local deployment 69 | using [docker-compose](deployments/local) already contains a MySQL server. 70 | 71 | ### Telemetry 72 | 73 | This project implements OpenTelemetry: It's being used to instrument, generate, collect, and export telemetry data ( 74 | metrics, logs, and traces) to help you analyze your software’s performance and behavior. 75 | 76 | #### Metrics (Prometheus & Grafana) 77 | 78 | This application utilizes [Prometheus](https://prometheus.io/), an open-source monitoring system featuring a dimensional 79 | data model, a flexible query language, an efficient time series database, and a modern alerting approach to generate 80 | metrics. These metrics can subsequently be visualized in [Grafana](https://grafana.com/), a multi-platform open-source 81 | analytics and interactive visualization web application. Grafana offers a range of visual elements, including charts, 82 | graphs, and alerts, seamlessly connected to supported data sources. 83 | 84 | Configuration for both services can be found in the `configs/` folder under their respective folder names. 85 | 86 | Metrics can be disabled by using the environment variable `METRICS_ENABLED=false`. 87 | 88 | #### Tracing (Jaeger) 89 | 90 | This application incorporates [Jaeger](https://www.jaegertracing.io/), an open-source software designed for tracing 91 | transactions between distributed services. Jaeger serves as a crucial tool for monitoring and troubleshooting complex 92 | microservices environments. With its tracing capabilities, Jaeger provides insights into the flow of transactions, 93 | aiding in the identification and resolution of issues within the distributed architecture. 94 | 95 | #### Logging (Zap + stdout) 96 | 97 | The application currently logs information to the standard output using [Zap](https://github.com/uber-go/zap), providing 98 | basic visibility into its operations. 99 | 100 | To configure different environments, the application relies on the `ENVIRONMENT` environment variable, which accepts the 101 | following values: `production` and `staging`. If no environment is explicitly set, logging will be disabled. 102 | 103 | To enhance and centralize the logging process, a [pull request](https://github.com/marcoshuck/todo/pull/52) is in 104 | progress 105 | to enable log aggregation using three key components: 106 | 107 | 1. **Logstash:** Logstash serves as a powerful log processing pipeline, facilitating the collection, transformation, and 108 | enrichment of log data. It acts as a central hub for managing logs from various sources. 109 | 110 | 2. **Elasticsearch:** Elasticsearch acts as a scalable and distributed search and analytics engine. It stores and 111 | indexes the processed logs, enabling efficient and fast retrieval of log data. 112 | 113 | 3. **Kibana:** Kibana is an open-source analytics and visualization platform. It provides a user-friendly interface for 114 | exploring, analyzing, and visualizing log data stored in Elasticsearch. With Kibana, users can create insightful 115 | dashboards, charts, and graphs to gain deeper insights into the application's performance. 116 | 117 | This integrated setup with Logstash, Elasticsearch, and Kibana ensures a robust and comprehensive logging solution, 118 | offering advanced capabilities for log processing, storage, and visualization. 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Marcos Huck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | tidy: 4 | go mod tidy 5 | 6 | generate: 7 | buf generate 8 | 9 | fmt: 10 | go fmt ./... 11 | 12 | vet: 13 | go vet -v ./... 14 | 15 | lint: 16 | buf lint 17 | golangci-lint -v run 18 | 19 | build/app: 20 | go build -a -ldflags '-extldflags "-static"' -o app ./cmd/app 21 | 22 | build/gw: 23 | go build -a -ldflags '-extldflags "-static"' -o gateway ./cmd/gateway 24 | 25 | build: build/app build/gw 26 | 27 | test: 28 | go test -race -covermode=atomic -coverprofile=coverage.tx -v ./... 29 | go tool cover -func=coverage.tx -o=coverage.out 30 | 31 | test-html: 32 | go test -race -covermode=atomic -coverprofile=coverage.out ./... 33 | go tool cover -html=coverage.out -o=coverage.html 34 | 35 | ci/compose-up: 36 | docker compose -f ./deployments/local/ci.docker-compose.yml up -d --build 37 | 38 | ci/compose-down: 39 | docker compose -f ./deployments/local/ci.docker-compose.yml down --rmi all 40 | 41 | test-e2e: 42 | npx playwright test 43 | 44 | ci/test-e2e: 45 | CI=true npx playwright test 46 | 47 | ci: ci/compose-up ci/test-e2e ci/compose-down 48 | 49 | all: generate tidy fmt vet lint test 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ToDo Application 2 | 3 | A web service written in Go, showcasing various features and technologies used in real world production-grade 4 | applications. 5 | 6 | ## Introduction 7 | 8 | Hi, I'm Marcos, a Software Engineer with a strong passion for crafting innovative distributed systems with more than 9 | four years of experience. 10 | 11 | This repository serves as my personal showcase, meticulously designed to demonstrate the power of Go in building 12 | production-grade web services that embrace cutting-edge technologies and deliver real-world value. 13 | 14 | ### Let's Connect! 15 | 16 | I'm excited to explore potential collaborations. Feel free to reach out to me 17 | at [marcos@huck.com.ar](mailto:marcos@huck.com.ar) or [add me on LinkedIn](https://linkedin.com/in/marcoshuck) to 18 | discuss your needs and embark on a journey together. 19 | 20 | **Services**: Software Engineering, Mentorships, Software architecture, Personalized training in any of the tools listed 21 | below. 22 | 23 | ## Design document 24 | 25 | In case you're interested in the technical explanation about this repository, feel free to check out 26 | the [DESIGN](DESIGN.md) document. 27 | 28 | ## Features 29 | - **Transport Mechanism**: [gRPC](https://grpc.io/) 30 | 31 | - **Infrastructure**: 32 | - Container engine: [Docker](https://www.docker.com/) 33 | - Local deployment: [Docker Compose](https://docs.docker.com/compose/) 34 | - Container orchestration: [Kubernetes](https://kubernetes.io/) 35 | - Provisioning: [Terraform](https://www.terraform.io/) (WIP) 36 | - Code generation: [Buf](https://buf.build/) 37 | 38 | - **Telemetry**: Supporting [OpenTelemetry](https://opentelemetry.io/) 39 | - Logging: [Zap](https://github.com/uber-go/zap) 40 | - Tracing: [Jaeger](https://www.jaegertracing.io/) 41 | - Metrics: [Prometheus](https://prometheus.io/) 42 | 43 | - **Testing & Benchmarking**: 44 | - [Playwright](https://playwright.dev/) for E2E testing 45 | 46 | - **Continuous Delivery** 47 | - Continuous Integration: [GitHub Actions](https://github.com/features/actions) 48 | - Continuous Deployment: TBD. 49 | 50 | ## Contributing 51 | 52 | Pull requests are welcome. For major changes, please open an issue first 53 | to discuss what you would like to change. 54 | 55 | Please make sure to update tests as appropriate. 56 | 57 | ## License 58 | 59 | [MIT](LICENSE) -------------------------------------------------------------------------------- /api/api.swagger.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | title: Todo API 4 | description: A web service written in Go, showcasing various features and technologies used in real world production-grade applications. 5 | version: "0.8" 6 | contact: 7 | name: Marcos Huck 8 | url: https://github.com/marcoshuck 9 | email: marcos@huck.com.ar 10 | license: 11 | name: MIT License 12 | url: https://github.com/marcoshuck/todo/blob/main/LICENSE 13 | tags: 14 | - name: TasksWriterService 15 | - name: TasksReaderService 16 | schemes: 17 | - https 18 | consumes: 19 | - application/json 20 | produces: 21 | - application/json 22 | paths: 23 | /v1/tasks: 24 | get: 25 | summary: ListTasks returns a list of Tasks. 26 | operationId: TasksReaderService_ListTasks 27 | responses: 28 | "200": 29 | description: A successful response. 30 | schema: 31 | $ref: '#/definitions/v1ListTasksResponse' 32 | default: 33 | description: An unexpected error response. 34 | schema: 35 | $ref: '#/definitions/rpcStatus' 36 | parameters: 37 | - name: pageSize 38 | in: query 39 | required: false 40 | type: integer 41 | format: int32 42 | - name: pageToken 43 | in: query 44 | required: false 45 | type: string 46 | tags: 47 | - TasksReaderService 48 | post: 49 | summary: CreateTask creates a Task. 50 | operationId: TasksWriterService_CreateTask 51 | responses: 52 | "200": 53 | description: A successful response. 54 | schema: 55 | $ref: '#/definitions/v1Task' 56 | default: 57 | description: An unexpected error response. 58 | schema: 59 | $ref: '#/definitions/rpcStatus' 60 | parameters: 61 | - name: task 62 | description: Task is the the task to create. 63 | in: body 64 | required: true 65 | schema: 66 | $ref: '#/definitions/v1Task' 67 | required: 68 | - task 69 | tags: 70 | - TasksWriterService 71 | /v1/tasks/{id}: 72 | get: 73 | summary: GetTask returns a Task. 74 | operationId: TasksReaderService_GetTask 75 | responses: 76 | "200": 77 | description: A successful response. 78 | schema: 79 | $ref: '#/definitions/v1Task' 80 | default: 81 | description: An unexpected error response. 82 | schema: 83 | $ref: '#/definitions/rpcStatus' 84 | parameters: 85 | - name: id 86 | in: path 87 | required: true 88 | type: string 89 | format: int64 90 | tags: 91 | - TasksReaderService 92 | delete: 93 | operationId: TasksWriterService_DeleteTask 94 | responses: 95 | "200": 96 | description: A successful response. 97 | schema: 98 | $ref: '#/definitions/v1Task' 99 | default: 100 | description: An unexpected error response. 101 | schema: 102 | $ref: '#/definitions/rpcStatus' 103 | parameters: 104 | - name: id 105 | in: path 106 | required: true 107 | type: string 108 | format: int64 109 | tags: 110 | - TasksWriterService 111 | /v1/tasks/{task.id}: 112 | patch: 113 | operationId: TasksWriterService_UpdateTask 114 | responses: 115 | "200": 116 | description: A successful response. 117 | schema: 118 | $ref: '#/definitions/v1Task' 119 | default: 120 | description: An unexpected error response. 121 | schema: 122 | $ref: '#/definitions/rpcStatus' 123 | parameters: 124 | - name: task.id 125 | in: path 126 | required: true 127 | type: string 128 | format: int64 129 | - name: task 130 | in: body 131 | required: true 132 | schema: 133 | type: object 134 | properties: 135 | title: 136 | type: string 137 | description: 138 | type: string 139 | deadline: 140 | type: string 141 | format: date-time 142 | completedAt: 143 | type: string 144 | format: date-time 145 | createTime: 146 | type: string 147 | format: date-time 148 | updateTime: 149 | type: string 150 | format: date-time 151 | tags: 152 | - TasksWriterService 153 | /v1/tasks:undelete: 154 | post: 155 | operationId: TasksWriterService_UndeleteTask 156 | responses: 157 | "200": 158 | description: A successful response. 159 | schema: 160 | $ref: '#/definitions/v1Task' 161 | default: 162 | description: An unexpected error response. 163 | schema: 164 | $ref: '#/definitions/rpcStatus' 165 | parameters: 166 | - name: body 167 | in: body 168 | required: true 169 | schema: 170 | $ref: '#/definitions/v1UndeleteTaskRequest' 171 | tags: 172 | - TasksWriterService 173 | definitions: 174 | protobufAny: 175 | type: object 176 | properties: 177 | '@type': 178 | type: string 179 | additionalProperties: {} 180 | rpcStatus: 181 | type: object 182 | properties: 183 | code: 184 | type: integer 185 | format: int32 186 | message: 187 | type: string 188 | details: 189 | type: array 190 | items: 191 | type: object 192 | $ref: '#/definitions/protobufAny' 193 | v1ListTasksResponse: 194 | type: object 195 | properties: 196 | tasks: 197 | type: array 198 | items: 199 | type: object 200 | $ref: '#/definitions/v1Task' 201 | nextPageToken: 202 | type: string 203 | v1Task: 204 | type: object 205 | properties: 206 | id: 207 | type: string 208 | format: int64 209 | title: 210 | type: string 211 | description: 212 | type: string 213 | deadline: 214 | type: string 215 | format: date-time 216 | completedAt: 217 | type: string 218 | format: date-time 219 | createTime: 220 | type: string 221 | format: date-time 222 | updateTime: 223 | type: string 224 | format: date-time 225 | v1UndeleteTaskRequest: 226 | type: object 227 | properties: 228 | id: 229 | type: string 230 | format: int64 231 | required: 232 | - id 233 | externalDocs: 234 | description: README 235 | url: https://github.com/marcoshuck/todo/blob/main/README.md 236 | -------------------------------------------------------------------------------- /api/tasks/v1/tasks.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package api.tasks.v1; 4 | 5 | import "google/api/annotations.proto"; 6 | import "google/api/field_behavior.proto"; 7 | import "google/api/resource.proto"; 8 | import "google/api/client.proto"; 9 | import "google/protobuf/timestamp.proto"; 10 | import "google/protobuf/field_mask.proto"; 11 | import "buf/validate/validate.proto"; 12 | import "protoc-gen-openapiv2/options/annotations.proto"; 13 | 14 | 15 | option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { 16 | info: { 17 | title: "Todo API"; 18 | version: "0.8"; 19 | description: "A web service written in Go, showcasing various features and technologies used in real world production-grade applications."; 20 | contact: { 21 | name: "Marcos Huck"; 22 | url: "https://github.com/marcoshuck"; 23 | email: "marcos@huck.com.ar"; 24 | }; 25 | license: { 26 | name: "MIT License"; 27 | url: "https://github.com/marcoshuck/todo/blob/main/LICENSE"; 28 | }; 29 | }; 30 | external_docs: { 31 | url: "https://github.com/marcoshuck/todo/blob/main/README.md"; 32 | description: "README"; 33 | }; 34 | schemes: HTTPS; 35 | consumes: "application/json"; 36 | produces: "application/json"; 37 | }; 38 | 39 | 40 | message Task { 41 | option (google.api.resource) = { 42 | type: "todo.huck.com.ar/Task" 43 | pattern: "tasks/{task}" 44 | singular: "task" 45 | plural: "tasks" 46 | }; 47 | 48 | int64 id = 1; 49 | string title = 2 [(buf.validate.field).string.min_len = 3]; 50 | string description = 3; 51 | google.protobuf.Timestamp deadline = 4; 52 | google.protobuf.Timestamp completed_at = 5; 53 | 54 | google.protobuf.Timestamp create_time = 1000; 55 | google.protobuf.Timestamp update_time = 1001; 56 | } 57 | 58 | message CreateTaskRequest { 59 | // The parent resource where this task will be created. 60 | // Format: projects/{project} 61 | reserved "parent"; 62 | reserved 1; 63 | 64 | // Task is the the task to create. 65 | Task task = 2 [(google.api.field_behavior) = REQUIRED]; 66 | } 67 | 68 | message DeleteTaskRequest { 69 | int64 id = 1 [ 70 | (buf.validate.field).int64.gt = 0, 71 | (google.api.field_behavior) = REQUIRED, 72 | (google.api.resource_reference) = { 73 | type: "todo.huck.com.ar/Task" 74 | }]; 75 | } 76 | 77 | message UndeleteTaskRequest { 78 | int64 id = 1 [ 79 | (buf.validate.field).int64.gt = 0, 80 | (google.api.field_behavior) = REQUIRED, 81 | (google.api.resource_reference) = { 82 | type: "todo.huck.com.ar/Task" 83 | }]; 84 | } 85 | 86 | message UpdateTaskRequest { 87 | Task task = 1 [(google.api.field_behavior) = REQUIRED, (buf.validate.field).skipped = true]; 88 | 89 | google.protobuf.FieldMask update_mask = 2; 90 | } 91 | 92 | // TasksWriterService holds the methods to persist, modify and remove Tasks. 93 | service TasksWriterService { 94 | // CreateTask creates a Task. 95 | rpc CreateTask(CreateTaskRequest) returns (Task) { 96 | option (google.api.http) = { 97 | post: "/v1/tasks"; 98 | body: "task"; 99 | }; 100 | option (google.api.method_signature) = "task"; 101 | }; 102 | rpc DeleteTask(DeleteTaskRequest) returns (Task) { 103 | option (google.api.http) = { 104 | delete: "/v1/tasks/{id}"; 105 | }; 106 | option (google.api.method_signature) = "id"; 107 | } 108 | rpc UndeleteTask(UndeleteTaskRequest) returns (Task) { 109 | option (google.api.http) = { 110 | post: "/v1/tasks:undelete"; 111 | body: "*"; 112 | }; 113 | option (google.api.method_signature) = "id"; 114 | } 115 | rpc UpdateTask(UpdateTaskRequest) returns (Task) { 116 | option (google.api.http) = { 117 | patch: "/v1/tasks/{task.id}"; 118 | body: "task"; 119 | }; 120 | option (google.api.method_signature) = "id,update_mask"; 121 | } 122 | } 123 | 124 | message GetTaskRequest { 125 | int64 id = 1 [ 126 | (buf.validate.field).int64.gt = 0, 127 | (google.api.field_behavior) = REQUIRED, 128 | (google.api.resource_reference) = { 129 | type: "todo.huck.com.ar/Task" 130 | }]; 131 | } 132 | 133 | message ListTasksRequest { 134 | reserved 1; 135 | reserved "parent"; 136 | 137 | int32 page_size = 2 [ 138 | (google.api.field_behavior) = OPTIONAL 139 | ]; 140 | string page_token = 3 [ 141 | (google.api.field_behavior) = OPTIONAL 142 | ]; 143 | } 144 | 145 | message ListTasksResponse { 146 | repeated Task tasks = 1; 147 | string next_page_token = 2; 148 | } 149 | 150 | // TasksReaderService holds the methods to obtain Tasks. 151 | service TasksReaderService { 152 | // GetTask returns a Task. 153 | rpc GetTask(GetTaskRequest) returns (Task) { 154 | option (google.api.http) = { 155 | get: "/v1/tasks/{id}"; 156 | }; 157 | option (google.api.method_signature) = "task"; 158 | }; 159 | 160 | // ListTasks returns a list of Tasks. 161 | rpc ListTasks(ListTasksRequest) returns (ListTasksResponse) { 162 | option (google.api.http) = { 163 | get: "/v1/tasks"; 164 | }; 165 | }; 166 | } -------------------------------------------------------------------------------- /api/tasks/v1/tasks_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.3.0 4 | // - protoc (unknown) 5 | // source: api/tasks/v1/tasks.proto 6 | 7 | package tasksv1 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.32.0 or later. 19 | const _ = grpc.SupportPackageIsVersion7 20 | 21 | const ( 22 | TasksWriterService_CreateTask_FullMethodName = "/api.tasks.v1.TasksWriterService/CreateTask" 23 | TasksWriterService_DeleteTask_FullMethodName = "/api.tasks.v1.TasksWriterService/DeleteTask" 24 | TasksWriterService_UndeleteTask_FullMethodName = "/api.tasks.v1.TasksWriterService/UndeleteTask" 25 | TasksWriterService_UpdateTask_FullMethodName = "/api.tasks.v1.TasksWriterService/UpdateTask" 26 | ) 27 | 28 | // TasksWriterServiceClient is the client API for TasksWriterService service. 29 | // 30 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 31 | type TasksWriterServiceClient interface { 32 | // CreateTask creates a Task. 33 | CreateTask(ctx context.Context, in *CreateTaskRequest, opts ...grpc.CallOption) (*Task, error) 34 | DeleteTask(ctx context.Context, in *DeleteTaskRequest, opts ...grpc.CallOption) (*Task, error) 35 | UndeleteTask(ctx context.Context, in *UndeleteTaskRequest, opts ...grpc.CallOption) (*Task, error) 36 | UpdateTask(ctx context.Context, in *UpdateTaskRequest, opts ...grpc.CallOption) (*Task, error) 37 | } 38 | 39 | type tasksWriterServiceClient struct { 40 | cc grpc.ClientConnInterface 41 | } 42 | 43 | func NewTasksWriterServiceClient(cc grpc.ClientConnInterface) TasksWriterServiceClient { 44 | return &tasksWriterServiceClient{cc} 45 | } 46 | 47 | func (c *tasksWriterServiceClient) CreateTask(ctx context.Context, in *CreateTaskRequest, opts ...grpc.CallOption) (*Task, error) { 48 | out := new(Task) 49 | err := c.cc.Invoke(ctx, TasksWriterService_CreateTask_FullMethodName, in, out, opts...) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return out, nil 54 | } 55 | 56 | func (c *tasksWriterServiceClient) DeleteTask(ctx context.Context, in *DeleteTaskRequest, opts ...grpc.CallOption) (*Task, error) { 57 | out := new(Task) 58 | err := c.cc.Invoke(ctx, TasksWriterService_DeleteTask_FullMethodName, in, out, opts...) 59 | if err != nil { 60 | return nil, err 61 | } 62 | return out, nil 63 | } 64 | 65 | func (c *tasksWriterServiceClient) UndeleteTask(ctx context.Context, in *UndeleteTaskRequest, opts ...grpc.CallOption) (*Task, error) { 66 | out := new(Task) 67 | err := c.cc.Invoke(ctx, TasksWriterService_UndeleteTask_FullMethodName, in, out, opts...) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return out, nil 72 | } 73 | 74 | func (c *tasksWriterServiceClient) UpdateTask(ctx context.Context, in *UpdateTaskRequest, opts ...grpc.CallOption) (*Task, error) { 75 | out := new(Task) 76 | err := c.cc.Invoke(ctx, TasksWriterService_UpdateTask_FullMethodName, in, out, opts...) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return out, nil 81 | } 82 | 83 | // TasksWriterServiceServer is the server API for TasksWriterService service. 84 | // All implementations must embed UnimplementedTasksWriterServiceServer 85 | // for forward compatibility 86 | type TasksWriterServiceServer interface { 87 | // CreateTask creates a Task. 88 | CreateTask(context.Context, *CreateTaskRequest) (*Task, error) 89 | DeleteTask(context.Context, *DeleteTaskRequest) (*Task, error) 90 | UndeleteTask(context.Context, *UndeleteTaskRequest) (*Task, error) 91 | UpdateTask(context.Context, *UpdateTaskRequest) (*Task, error) 92 | mustEmbedUnimplementedTasksWriterServiceServer() 93 | } 94 | 95 | // UnimplementedTasksWriterServiceServer must be embedded to have forward compatible implementations. 96 | type UnimplementedTasksWriterServiceServer struct { 97 | } 98 | 99 | func (UnimplementedTasksWriterServiceServer) CreateTask(context.Context, *CreateTaskRequest) (*Task, error) { 100 | return nil, status.Errorf(codes.Unimplemented, "method CreateTask not implemented") 101 | } 102 | func (UnimplementedTasksWriterServiceServer) DeleteTask(context.Context, *DeleteTaskRequest) (*Task, error) { 103 | return nil, status.Errorf(codes.Unimplemented, "method DeleteTask not implemented") 104 | } 105 | func (UnimplementedTasksWriterServiceServer) UndeleteTask(context.Context, *UndeleteTaskRequest) (*Task, error) { 106 | return nil, status.Errorf(codes.Unimplemented, "method UndeleteTask not implemented") 107 | } 108 | func (UnimplementedTasksWriterServiceServer) UpdateTask(context.Context, *UpdateTaskRequest) (*Task, error) { 109 | return nil, status.Errorf(codes.Unimplemented, "method UpdateTask not implemented") 110 | } 111 | func (UnimplementedTasksWriterServiceServer) mustEmbedUnimplementedTasksWriterServiceServer() {} 112 | 113 | // UnsafeTasksWriterServiceServer may be embedded to opt out of forward compatibility for this service. 114 | // Use of this interface is not recommended, as added methods to TasksWriterServiceServer will 115 | // result in compilation errors. 116 | type UnsafeTasksWriterServiceServer interface { 117 | mustEmbedUnimplementedTasksWriterServiceServer() 118 | } 119 | 120 | func RegisterTasksWriterServiceServer(s grpc.ServiceRegistrar, srv TasksWriterServiceServer) { 121 | s.RegisterService(&TasksWriterService_ServiceDesc, srv) 122 | } 123 | 124 | func _TasksWriterService_CreateTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 125 | in := new(CreateTaskRequest) 126 | if err := dec(in); err != nil { 127 | return nil, err 128 | } 129 | if interceptor == nil { 130 | return srv.(TasksWriterServiceServer).CreateTask(ctx, in) 131 | } 132 | info := &grpc.UnaryServerInfo{ 133 | Server: srv, 134 | FullMethod: TasksWriterService_CreateTask_FullMethodName, 135 | } 136 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 137 | return srv.(TasksWriterServiceServer).CreateTask(ctx, req.(*CreateTaskRequest)) 138 | } 139 | return interceptor(ctx, in, info, handler) 140 | } 141 | 142 | func _TasksWriterService_DeleteTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 143 | in := new(DeleteTaskRequest) 144 | if err := dec(in); err != nil { 145 | return nil, err 146 | } 147 | if interceptor == nil { 148 | return srv.(TasksWriterServiceServer).DeleteTask(ctx, in) 149 | } 150 | info := &grpc.UnaryServerInfo{ 151 | Server: srv, 152 | FullMethod: TasksWriterService_DeleteTask_FullMethodName, 153 | } 154 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 155 | return srv.(TasksWriterServiceServer).DeleteTask(ctx, req.(*DeleteTaskRequest)) 156 | } 157 | return interceptor(ctx, in, info, handler) 158 | } 159 | 160 | func _TasksWriterService_UndeleteTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 161 | in := new(UndeleteTaskRequest) 162 | if err := dec(in); err != nil { 163 | return nil, err 164 | } 165 | if interceptor == nil { 166 | return srv.(TasksWriterServiceServer).UndeleteTask(ctx, in) 167 | } 168 | info := &grpc.UnaryServerInfo{ 169 | Server: srv, 170 | FullMethod: TasksWriterService_UndeleteTask_FullMethodName, 171 | } 172 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 173 | return srv.(TasksWriterServiceServer).UndeleteTask(ctx, req.(*UndeleteTaskRequest)) 174 | } 175 | return interceptor(ctx, in, info, handler) 176 | } 177 | 178 | func _TasksWriterService_UpdateTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 179 | in := new(UpdateTaskRequest) 180 | if err := dec(in); err != nil { 181 | return nil, err 182 | } 183 | if interceptor == nil { 184 | return srv.(TasksWriterServiceServer).UpdateTask(ctx, in) 185 | } 186 | info := &grpc.UnaryServerInfo{ 187 | Server: srv, 188 | FullMethod: TasksWriterService_UpdateTask_FullMethodName, 189 | } 190 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 191 | return srv.(TasksWriterServiceServer).UpdateTask(ctx, req.(*UpdateTaskRequest)) 192 | } 193 | return interceptor(ctx, in, info, handler) 194 | } 195 | 196 | // TasksWriterService_ServiceDesc is the grpc.ServiceDesc for TasksWriterService service. 197 | // It's only intended for direct use with grpc.RegisterService, 198 | // and not to be introspected or modified (even as a copy) 199 | var TasksWriterService_ServiceDesc = grpc.ServiceDesc{ 200 | ServiceName: "api.tasks.v1.TasksWriterService", 201 | HandlerType: (*TasksWriterServiceServer)(nil), 202 | Methods: []grpc.MethodDesc{ 203 | { 204 | MethodName: "CreateTask", 205 | Handler: _TasksWriterService_CreateTask_Handler, 206 | }, 207 | { 208 | MethodName: "DeleteTask", 209 | Handler: _TasksWriterService_DeleteTask_Handler, 210 | }, 211 | { 212 | MethodName: "UndeleteTask", 213 | Handler: _TasksWriterService_UndeleteTask_Handler, 214 | }, 215 | { 216 | MethodName: "UpdateTask", 217 | Handler: _TasksWriterService_UpdateTask_Handler, 218 | }, 219 | }, 220 | Streams: []grpc.StreamDesc{}, 221 | Metadata: "api/tasks/v1/tasks.proto", 222 | } 223 | 224 | const ( 225 | TasksReaderService_GetTask_FullMethodName = "/api.tasks.v1.TasksReaderService/GetTask" 226 | TasksReaderService_ListTasks_FullMethodName = "/api.tasks.v1.TasksReaderService/ListTasks" 227 | ) 228 | 229 | // TasksReaderServiceClient is the client API for TasksReaderService service. 230 | // 231 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 232 | type TasksReaderServiceClient interface { 233 | // GetTask returns a Task. 234 | GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*Task, error) 235 | // ListTasks returns a list of Tasks. 236 | ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error) 237 | } 238 | 239 | type tasksReaderServiceClient struct { 240 | cc grpc.ClientConnInterface 241 | } 242 | 243 | func NewTasksReaderServiceClient(cc grpc.ClientConnInterface) TasksReaderServiceClient { 244 | return &tasksReaderServiceClient{cc} 245 | } 246 | 247 | func (c *tasksReaderServiceClient) GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*Task, error) { 248 | out := new(Task) 249 | err := c.cc.Invoke(ctx, TasksReaderService_GetTask_FullMethodName, in, out, opts...) 250 | if err != nil { 251 | return nil, err 252 | } 253 | return out, nil 254 | } 255 | 256 | func (c *tasksReaderServiceClient) ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error) { 257 | out := new(ListTasksResponse) 258 | err := c.cc.Invoke(ctx, TasksReaderService_ListTasks_FullMethodName, in, out, opts...) 259 | if err != nil { 260 | return nil, err 261 | } 262 | return out, nil 263 | } 264 | 265 | // TasksReaderServiceServer is the server API for TasksReaderService service. 266 | // All implementations must embed UnimplementedTasksReaderServiceServer 267 | // for forward compatibility 268 | type TasksReaderServiceServer interface { 269 | // GetTask returns a Task. 270 | GetTask(context.Context, *GetTaskRequest) (*Task, error) 271 | // ListTasks returns a list of Tasks. 272 | ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error) 273 | mustEmbedUnimplementedTasksReaderServiceServer() 274 | } 275 | 276 | // UnimplementedTasksReaderServiceServer must be embedded to have forward compatible implementations. 277 | type UnimplementedTasksReaderServiceServer struct { 278 | } 279 | 280 | func (UnimplementedTasksReaderServiceServer) GetTask(context.Context, *GetTaskRequest) (*Task, error) { 281 | return nil, status.Errorf(codes.Unimplemented, "method GetTask not implemented") 282 | } 283 | func (UnimplementedTasksReaderServiceServer) ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error) { 284 | return nil, status.Errorf(codes.Unimplemented, "method ListTasks not implemented") 285 | } 286 | func (UnimplementedTasksReaderServiceServer) mustEmbedUnimplementedTasksReaderServiceServer() {} 287 | 288 | // UnsafeTasksReaderServiceServer may be embedded to opt out of forward compatibility for this service. 289 | // Use of this interface is not recommended, as added methods to TasksReaderServiceServer will 290 | // result in compilation errors. 291 | type UnsafeTasksReaderServiceServer interface { 292 | mustEmbedUnimplementedTasksReaderServiceServer() 293 | } 294 | 295 | func RegisterTasksReaderServiceServer(s grpc.ServiceRegistrar, srv TasksReaderServiceServer) { 296 | s.RegisterService(&TasksReaderService_ServiceDesc, srv) 297 | } 298 | 299 | func _TasksReaderService_GetTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 300 | in := new(GetTaskRequest) 301 | if err := dec(in); err != nil { 302 | return nil, err 303 | } 304 | if interceptor == nil { 305 | return srv.(TasksReaderServiceServer).GetTask(ctx, in) 306 | } 307 | info := &grpc.UnaryServerInfo{ 308 | Server: srv, 309 | FullMethod: TasksReaderService_GetTask_FullMethodName, 310 | } 311 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 312 | return srv.(TasksReaderServiceServer).GetTask(ctx, req.(*GetTaskRequest)) 313 | } 314 | return interceptor(ctx, in, info, handler) 315 | } 316 | 317 | func _TasksReaderService_ListTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 318 | in := new(ListTasksRequest) 319 | if err := dec(in); err != nil { 320 | return nil, err 321 | } 322 | if interceptor == nil { 323 | return srv.(TasksReaderServiceServer).ListTasks(ctx, in) 324 | } 325 | info := &grpc.UnaryServerInfo{ 326 | Server: srv, 327 | FullMethod: TasksReaderService_ListTasks_FullMethodName, 328 | } 329 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 330 | return srv.(TasksReaderServiceServer).ListTasks(ctx, req.(*ListTasksRequest)) 331 | } 332 | return interceptor(ctx, in, info, handler) 333 | } 334 | 335 | // TasksReaderService_ServiceDesc is the grpc.ServiceDesc for TasksReaderService service. 336 | // It's only intended for direct use with grpc.RegisterService, 337 | // and not to be introspected or modified (even as a copy) 338 | var TasksReaderService_ServiceDesc = grpc.ServiceDesc{ 339 | ServiceName: "api.tasks.v1.TasksReaderService", 340 | HandlerType: (*TasksReaderServiceServer)(nil), 341 | Methods: []grpc.MethodDesc{ 342 | { 343 | MethodName: "GetTask", 344 | Handler: _TasksReaderService_GetTask_Handler, 345 | }, 346 | { 347 | MethodName: "ListTasks", 348 | Handler: _TasksReaderService_ListTasks_Handler, 349 | }, 350 | }, 351 | Streams: []grpc.StreamDesc{}, 352 | Metadata: "api/tasks/v1/tasks.proto", 353 | } 354 | -------------------------------------------------------------------------------- /api/tasks/v1/tasks_pb.d.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es v1.10.0 2 | // @generated from file api/tasks/v1/tasks.proto (package api.tasks.v1, syntax proto3) 3 | /* eslint-disable */ 4 | // @ts-nocheck 5 | 6 | import type { 7 | BinaryReadOptions, 8 | FieldList, 9 | FieldMask, 10 | JsonReadOptions, 11 | JsonValue, 12 | PartialMessage, 13 | PlainMessage, 14 | Timestamp 15 | } from "@bufbuild/protobuf"; 16 | import {Message, proto3} from "@bufbuild/protobuf"; 17 | 18 | /** 19 | * @generated from message api.tasks.v1.Task 20 | */ 21 | export declare class Task extends Message { 22 | /** 23 | * @generated from field: int64 id = 1; 24 | */ 25 | id: bigint; 26 | 27 | /** 28 | * @generated from field: string title = 2; 29 | */ 30 | title: string; 31 | 32 | /** 33 | * @generated from field: string description = 3; 34 | */ 35 | description: string; 36 | 37 | /** 38 | * @generated from field: google.protobuf.Timestamp deadline = 4; 39 | */ 40 | deadline?: Timestamp; 41 | 42 | /** 43 | * @generated from field: google.protobuf.Timestamp completed_at = 5; 44 | */ 45 | completedAt?: Timestamp; 46 | 47 | /** 48 | * @generated from field: google.protobuf.Timestamp create_time = 1000; 49 | */ 50 | createTime?: Timestamp; 51 | 52 | /** 53 | * @generated from field: google.protobuf.Timestamp update_time = 1001; 54 | */ 55 | updateTime?: Timestamp; 56 | 57 | constructor(data?: PartialMessage); 58 | 59 | static readonly runtime: typeof proto3; 60 | static readonly typeName = "api.tasks.v1.Task"; 61 | static readonly fields: FieldList; 62 | 63 | static fromBinary(bytes: Uint8Array, options?: Partial): Task; 64 | 65 | static fromJson(jsonValue: JsonValue, options?: Partial): Task; 66 | 67 | static fromJsonString(jsonString: string, options?: Partial): Task; 68 | 69 | static equals(a: Task | PlainMessage | undefined, b: Task | PlainMessage | undefined): boolean; 70 | } 71 | 72 | /** 73 | * @generated from message api.tasks.v1.CreateTaskRequest 74 | */ 75 | export declare class CreateTaskRequest extends Message { 76 | /** 77 | * Task is the the task to create. 78 | * 79 | * @generated from field: api.tasks.v1.Task task = 2; 80 | */ 81 | task?: Task; 82 | 83 | constructor(data?: PartialMessage); 84 | 85 | static readonly runtime: typeof proto3; 86 | static readonly typeName = "api.tasks.v1.CreateTaskRequest"; 87 | static readonly fields: FieldList; 88 | 89 | static fromBinary(bytes: Uint8Array, options?: Partial): CreateTaskRequest; 90 | 91 | static fromJson(jsonValue: JsonValue, options?: Partial): CreateTaskRequest; 92 | 93 | static fromJsonString(jsonString: string, options?: Partial): CreateTaskRequest; 94 | 95 | static equals(a: CreateTaskRequest | PlainMessage | undefined, b: CreateTaskRequest | PlainMessage | undefined): boolean; 96 | } 97 | 98 | /** 99 | * @generated from message api.tasks.v1.DeleteTaskRequest 100 | */ 101 | export declare class DeleteTaskRequest extends Message { 102 | /** 103 | * @generated from field: int64 id = 1; 104 | */ 105 | id: bigint; 106 | 107 | constructor(data?: PartialMessage); 108 | 109 | static readonly runtime: typeof proto3; 110 | static readonly typeName = "api.tasks.v1.DeleteTaskRequest"; 111 | static readonly fields: FieldList; 112 | 113 | static fromBinary(bytes: Uint8Array, options?: Partial): DeleteTaskRequest; 114 | 115 | static fromJson(jsonValue: JsonValue, options?: Partial): DeleteTaskRequest; 116 | 117 | static fromJsonString(jsonString: string, options?: Partial): DeleteTaskRequest; 118 | 119 | static equals(a: DeleteTaskRequest | PlainMessage | undefined, b: DeleteTaskRequest | PlainMessage | undefined): boolean; 120 | } 121 | 122 | /** 123 | * @generated from message api.tasks.v1.UndeleteTaskRequest 124 | */ 125 | export declare class UndeleteTaskRequest extends Message { 126 | /** 127 | * @generated from field: int64 id = 1; 128 | */ 129 | id: bigint; 130 | 131 | constructor(data?: PartialMessage); 132 | 133 | static readonly runtime: typeof proto3; 134 | static readonly typeName = "api.tasks.v1.UndeleteTaskRequest"; 135 | static readonly fields: FieldList; 136 | 137 | static fromBinary(bytes: Uint8Array, options?: Partial): UndeleteTaskRequest; 138 | 139 | static fromJson(jsonValue: JsonValue, options?: Partial): UndeleteTaskRequest; 140 | 141 | static fromJsonString(jsonString: string, options?: Partial): UndeleteTaskRequest; 142 | 143 | static equals(a: UndeleteTaskRequest | PlainMessage | undefined, b: UndeleteTaskRequest | PlainMessage | undefined): boolean; 144 | } 145 | 146 | /** 147 | * @generated from message api.tasks.v1.UpdateTaskRequest 148 | */ 149 | export declare class UpdateTaskRequest extends Message { 150 | /** 151 | * @generated from field: api.tasks.v1.Task task = 1; 152 | */ 153 | task?: Task; 154 | 155 | /** 156 | * @generated from field: google.protobuf.FieldMask update_mask = 2; 157 | */ 158 | updateMask?: FieldMask; 159 | 160 | constructor(data?: PartialMessage); 161 | 162 | static readonly runtime: typeof proto3; 163 | static readonly typeName = "api.tasks.v1.UpdateTaskRequest"; 164 | static readonly fields: FieldList; 165 | 166 | static fromBinary(bytes: Uint8Array, options?: Partial): UpdateTaskRequest; 167 | 168 | static fromJson(jsonValue: JsonValue, options?: Partial): UpdateTaskRequest; 169 | 170 | static fromJsonString(jsonString: string, options?: Partial): UpdateTaskRequest; 171 | 172 | static equals(a: UpdateTaskRequest | PlainMessage | undefined, b: UpdateTaskRequest | PlainMessage | undefined): boolean; 173 | } 174 | 175 | /** 176 | * @generated from message api.tasks.v1.GetTaskRequest 177 | */ 178 | export declare class GetTaskRequest extends Message { 179 | /** 180 | * @generated from field: int64 id = 1; 181 | */ 182 | id: bigint; 183 | 184 | constructor(data?: PartialMessage); 185 | 186 | static readonly runtime: typeof proto3; 187 | static readonly typeName = "api.tasks.v1.GetTaskRequest"; 188 | static readonly fields: FieldList; 189 | 190 | static fromBinary(bytes: Uint8Array, options?: Partial): GetTaskRequest; 191 | 192 | static fromJson(jsonValue: JsonValue, options?: Partial): GetTaskRequest; 193 | 194 | static fromJsonString(jsonString: string, options?: Partial): GetTaskRequest; 195 | 196 | static equals(a: GetTaskRequest | PlainMessage | undefined, b: GetTaskRequest | PlainMessage | undefined): boolean; 197 | } 198 | 199 | /** 200 | * @generated from message api.tasks.v1.ListTasksRequest 201 | */ 202 | export declare class ListTasksRequest extends Message { 203 | /** 204 | * @generated from field: int32 page_size = 2; 205 | */ 206 | pageSize: number; 207 | 208 | /** 209 | * @generated from field: string page_token = 3; 210 | */ 211 | pageToken: string; 212 | 213 | constructor(data?: PartialMessage); 214 | 215 | static readonly runtime: typeof proto3; 216 | static readonly typeName = "api.tasks.v1.ListTasksRequest"; 217 | static readonly fields: FieldList; 218 | 219 | static fromBinary(bytes: Uint8Array, options?: Partial): ListTasksRequest; 220 | 221 | static fromJson(jsonValue: JsonValue, options?: Partial): ListTasksRequest; 222 | 223 | static fromJsonString(jsonString: string, options?: Partial): ListTasksRequest; 224 | 225 | static equals(a: ListTasksRequest | PlainMessage | undefined, b: ListTasksRequest | PlainMessage | undefined): boolean; 226 | } 227 | 228 | /** 229 | * @generated from message api.tasks.v1.ListTasksResponse 230 | */ 231 | export declare class ListTasksResponse extends Message { 232 | /** 233 | * @generated from field: repeated api.tasks.v1.Task tasks = 1; 234 | */ 235 | tasks: Task[]; 236 | 237 | /** 238 | * @generated from field: string next_page_token = 2; 239 | */ 240 | nextPageToken: string; 241 | 242 | constructor(data?: PartialMessage); 243 | 244 | static readonly runtime: typeof proto3; 245 | static readonly typeName = "api.tasks.v1.ListTasksResponse"; 246 | static readonly fields: FieldList; 247 | 248 | static fromBinary(bytes: Uint8Array, options?: Partial): ListTasksResponse; 249 | 250 | static fromJson(jsonValue: JsonValue, options?: Partial): ListTasksResponse; 251 | 252 | static fromJsonString(jsonString: string, options?: Partial): ListTasksResponse; 253 | 254 | static equals(a: ListTasksResponse | PlainMessage | undefined, b: ListTasksResponse | PlainMessage | undefined): boolean; 255 | } 256 | 257 | -------------------------------------------------------------------------------- /api/tasks/v1/tasks_pb.js: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es v1.10.0 2 | // @generated from file api/tasks/v1/tasks.proto (package api.tasks.v1, syntax proto3) 3 | /* eslint-disable */ 4 | // @ts-nocheck 5 | 6 | import {FieldMask, proto3, Timestamp} from "@bufbuild/protobuf"; 7 | 8 | /** 9 | * @generated from message api.tasks.v1.Task 10 | */ 11 | export const Task = /*@__PURE__*/ proto3.makeMessageType( 12 | "api.tasks.v1.Task", 13 | () => [ 14 | { no: 1, name: "id", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, 15 | { no: 2, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 16 | { no: 3, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 17 | { no: 4, name: "deadline", kind: "message", T: Timestamp }, 18 | { no: 5, name: "completed_at", kind: "message", T: Timestamp }, 19 | { no: 1000, name: "create_time", kind: "message", T: Timestamp }, 20 | { no: 1001, name: "update_time", kind: "message", T: Timestamp }, 21 | ], 22 | ); 23 | 24 | /** 25 | * @generated from message api.tasks.v1.CreateTaskRequest 26 | */ 27 | export const CreateTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 28 | "api.tasks.v1.CreateTaskRequest", 29 | () => [ 30 | { no: 2, name: "task", kind: "message", T: Task }, 31 | ], 32 | ); 33 | 34 | /** 35 | * @generated from message api.tasks.v1.DeleteTaskRequest 36 | */ 37 | export const DeleteTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 38 | "api.tasks.v1.DeleteTaskRequest", 39 | () => [ 40 | { no: 1, name: "id", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, 41 | ], 42 | ); 43 | 44 | /** 45 | * @generated from message api.tasks.v1.UndeleteTaskRequest 46 | */ 47 | export const UndeleteTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 48 | "api.tasks.v1.UndeleteTaskRequest", 49 | () => [ 50 | { no: 1, name: "id", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, 51 | ], 52 | ); 53 | 54 | /** 55 | * @generated from message api.tasks.v1.UpdateTaskRequest 56 | */ 57 | export const UpdateTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 58 | "api.tasks.v1.UpdateTaskRequest", 59 | () => [ 60 | { no: 1, name: "task", kind: "message", T: Task }, 61 | { no: 2, name: "update_mask", kind: "message", T: FieldMask }, 62 | ], 63 | ); 64 | 65 | /** 66 | * @generated from message api.tasks.v1.GetTaskRequest 67 | */ 68 | export const GetTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 69 | "api.tasks.v1.GetTaskRequest", 70 | () => [ 71 | { no: 1, name: "id", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, 72 | ], 73 | ); 74 | 75 | /** 76 | * @generated from message api.tasks.v1.ListTasksRequest 77 | */ 78 | export const ListTasksRequest = /*@__PURE__*/ proto3.makeMessageType( 79 | "api.tasks.v1.ListTasksRequest", 80 | () => [ 81 | { no: 2, name: "page_size", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, 82 | { no: 3, name: "page_token", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 83 | ], 84 | ); 85 | 86 | /** 87 | * @generated from message api.tasks.v1.ListTasksResponse 88 | */ 89 | export const ListTasksResponse = /*@__PURE__*/ proto3.makeMessageType( 90 | "api.tasks.v1.ListTasksResponse", 91 | () => [ 92 | { no: 1, name: "tasks", kind: "message", T: Task, repeated: true }, 93 | { no: 2, name: "next_page_token", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 94 | ], 95 | ); 96 | 97 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | managed: 3 | enabled: true 4 | go_package_prefix: 5 | default: github.com/marcoshuck/todo # / 6 | except: 7 | - buf.build/googleapis/googleapis 8 | - buf.build/grpc-ecosystem/grpc-gateway 9 | - buf.build/bufbuild/protovalidate 10 | plugins: 11 | - plugin: buf.build/protocolbuffers/go 12 | out: . 13 | opt: 14 | - paths=source_relative 15 | - plugin: buf.build/grpc/go:v1.3.0 16 | out: . 17 | opt: 18 | - paths=source_relative 19 | - plugin: buf.build/grpc-ecosystem/gateway:v2.20.0 20 | out: . 21 | opt: 22 | - paths=source_relative 23 | - logtostderr=true 24 | - plugin: buf.build/grpc-ecosystem/openapiv2:v2.20.0 25 | out: api 26 | opt: 27 | - allow_merge=true 28 | - merge_file_name=api 29 | - output_format=yaml 30 | - plugin: buf.build/bufbuild/es:v1.10.0 31 | out: . 32 | - plugin: buf.build/bufbuild/es:v1.10.0 33 | out: ./ui/src -------------------------------------------------------------------------------- /buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v1 3 | deps: 4 | - remote: buf.build 5 | owner: bufbuild 6 | repository: protovalidate 7 | commit: 1baebb0a15184714854fa1ddfd22a29b 8 | digest: shake256:ecd24ac37ca4f1ea65d816ae98e2a977e5004076ac957de9aedc0d4b517c66eaf3b7c8d6c7669ffd6e2eca5e8a5705372bed339ba47a608b37183ef1ee4f8baf 9 | - remote: buf.build 10 | owner: googleapis 11 | repository: googleapis 12 | commit: 28151c0d0a1641bf938a7672c500e01d 13 | digest: shake256:49215edf8ef57f7863004539deff8834cfb2195113f0b890dd1f67815d9353e28e668019165b9d872395871eeafcbab3ccfdb2b5f11734d3cca95be9e8d139de 14 | - remote: buf.build 15 | owner: grpc-ecosystem 16 | repository: grpc-gateway 17 | commit: 3f42134f4c564983838425bc43c7a65f 18 | digest: shake256:3d11d4c0fe5e05fda0131afefbce233940e27f0c31c5d4e385686aea58ccd30f72053f61af432fa83f1fc11cda57f5f18ca3da26a29064f73c5a0d076bba8d92 19 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | deps: 3 | - buf.build/googleapis/googleapis 4 | - buf.build/grpc-ecosystem/grpc-gateway 5 | - buf.build/bufbuild/protovalidate 6 | breaking: 7 | use: 8 | - FILE 9 | lint: 10 | use: 11 | - DEFAULT 12 | - RPC_RESPONSE_STANDARD_NAME 13 | except: 14 | - RPC_RESPONSE_STANDARD_NAME 15 | - RPC_REQUEST_RESPONSE_UNIQUE 16 | enum_zero_value_suffix: _UNSPECIFIED 17 | rpc_allow_google_protobuf_empty_responses: true 18 | service_suffix: Service 19 | -------------------------------------------------------------------------------- /build/app.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22 AS builder 2 | 3 | WORKDIR /build 4 | 5 | COPY go.mod . 6 | COPY go.sum . 7 | 8 | RUN go mod download 9 | 10 | COPY . . 11 | 12 | RUN CGO_ENABLED=1 go build -a -ldflags '-extldflags "-static"' -o app ./cmd/app 13 | 14 | WORKDIR /dist 15 | RUN cp /build/app . 16 | 17 | FROM alpine 18 | 19 | COPY --chown=0:0 --from=builder /dist / 20 | 21 | USER 65534 22 | CMD ["/app"] -------------------------------------------------------------------------------- /build/ci/dagger/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "dagger.io/dagger" 6 | "fmt" 7 | "log" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | pipeline = map[string][]Job{ 14 | "format": { 15 | { 16 | Image: "golang:1.21", 17 | Name: "fmt", 18 | Command: "go fmt ./...", 19 | }, 20 | { 21 | Image: "golang:1.21", 22 | Name: "vet", 23 | Command: "go vet -v ./...", 24 | }, 25 | { 26 | Image: "golangci/golangci-lint:v1.54.1", 27 | Name: "lint", 28 | Command: "golangci-lint run -v", 29 | }, 30 | }, 31 | "test": { 32 | { 33 | Image: "golang:1.21", 34 | Name: "race", 35 | Command: "go test -race ./...", 36 | }, 37 | { 38 | Image: "golang:1.21", 39 | Name: "test", 40 | Command: "go test -covermode=atomic -coverprofile=coverage.tx -v ./...", 41 | }, 42 | }, 43 | } 44 | ) 45 | 46 | type Job struct { 47 | Name string 48 | Command string 49 | Image string 50 | } 51 | 52 | func main() { 53 | ctx := context.Background() 54 | client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr)) 55 | if err != nil { 56 | log.Fatalln("Error while running Dagger:", err) 57 | } 58 | defer client.Close() 59 | 60 | src := getSourceDirectory(client) 61 | 62 | for stageName, jobs := range pipeline { 63 | for _, job := range jobs { 64 | ci := client.Container().Pipeline(fmt.Sprintf("%s/%s", stageName, job.Name)). 65 | From(job.Image). 66 | WithDirectory("/src", src). 67 | WithWorkdir("/src"). 68 | WithExec(strings.Split(job.Command, " ")) 69 | 70 | _, err := ci.Stderr(ctx) 71 | if err != nil { 72 | log.Printf("Job %s/%s failed with error: %v\n", stageName, job.Name, err) 73 | continue 74 | } 75 | log.Printf("Job %s/%s succeeded\n", stageName, job.Name) 76 | } 77 | } 78 | 79 | // Pipeline: Format, Test, Build 80 | // Format: 81 | // - golangci-lint 82 | // - go fmt 83 | // - go vet ./... 84 | // Test with MySQL 85 | // - Race conditions 86 | // - Unit tests 87 | // - Integration tests 88 | } 89 | 90 | func getSourceDirectory(client *dagger.Client) *dagger.Directory { 91 | return client.Host().Directory(".", dagger.HostDirectoryOpts{ 92 | Exclude: []string{"build/ci", "deployments", ".gitignore", "buf.*"}, 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /build/gateway.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22 AS builder 2 | 3 | WORKDIR /build 4 | 5 | COPY go.mod . 6 | COPY go.sum . 7 | 8 | RUN go mod download 9 | 10 | COPY . . 11 | 12 | RUN CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o gateway ./cmd/gateway 13 | 14 | WORKDIR /dist 15 | RUN cp /build/gateway . 16 | 17 | FROM alpine 18 | 19 | COPY --chown=0:0 --from=builder /dist / 20 | 21 | USER 65534 22 | CMD ["/gateway"] -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/marcoshuck/todo/internal/conf" 5 | "github.com/marcoshuck/todo/internal/server" 6 | "log" 7 | ) 8 | 9 | func main() { 10 | cfg, err := conf.ReadServerConfig() 11 | if err != nil { 12 | log.Fatalln("Failed to read configuration:", err) 13 | } 14 | app, err := server.Setup(cfg) 15 | if err != nil { 16 | log.Fatalln("Failed to initialize application:", err) 17 | } 18 | if err := server.Run(app); err != nil { 19 | log.Fatalln("Application exited abruptly:", err) 20 | } 21 | log.Println("Closing application...") 22 | } 23 | -------------------------------------------------------------------------------- /cmd/gateway/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/marcoshuck/todo/internal/conf" 6 | "github.com/marcoshuck/todo/internal/gateway" 7 | "log" 8 | ) 9 | 10 | func main() { 11 | ctx := context.Background() 12 | 13 | cfg, err := conf.ReadGatewayConfig() 14 | if err != nil { 15 | log.Fatalln("Failed to read client config:", err) 16 | } 17 | 18 | gw, err := gateway.Setup(ctx, cfg) 19 | if err != nil { 20 | log.Fatalln("Failed to initialize gateway:", err) 21 | } 22 | if err := gateway.Run(gw); err != nil { 23 | log.Fatalln("Failed to run gateway:", err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /configs/grafana/datasource.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | url: http://prometheus:9090 7 | isDefault: true 8 | access: proxy 9 | editable: true -------------------------------------------------------------------------------- /configs/otel_collector/config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | 6 | exporters: 7 | prometheus: 8 | endpoint: "0.0.0.0:8889" 9 | namespace: todo 10 | const_labels: 11 | app: todo 12 | logging: 13 | loglevel: debug 14 | 15 | otlp/jaeger: 16 | endpoint: "jaeger:4317" 17 | tls: 18 | insecure: true 19 | 20 | processors: 21 | batch: 22 | 23 | extensions: 24 | health_check: 25 | pprof: 26 | endpoint: :1888 27 | zpages: 28 | endpoint: :55679 29 | 30 | service: 31 | extensions: [pprof, zpages, health_check] 32 | pipelines: 33 | traces: 34 | receivers: [otlp] 35 | processors: [batch] 36 | exporters: [ otlp/jaeger] 37 | metrics: 38 | receivers: [otlp] 39 | processors: [batch] 40 | exporters: [prometheus] -------------------------------------------------------------------------------- /configs/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | scrape_timeout: 10s 4 | evaluation_interval: 15s 5 | alerting: 6 | alertmanagers: 7 | - static_configs: 8 | - targets: [] 9 | scheme: http 10 | timeout: 10s 11 | api_version: v1 12 | scrape_configs: 13 | - job_name: prometheus 14 | honor_timestamps: true 15 | scrape_interval: 15s 16 | scrape_timeout: 10s 17 | metrics_path: /metrics 18 | scheme: http 19 | static_configs: 20 | - targets: ['collector:8889'] 21 | - targets: ['collector:8888'] 22 | - job_name: gateway 23 | honor_timestamps: true 24 | scrape_interval: 15s 25 | scrape_timeout: 10s 26 | metrics_path: /metrics 27 | scheme: http 28 | static_configs: 29 | - targets: [ 'gateway:8080' ] 30 | - job_name: server 31 | honor_timestamps: true 32 | scrape_interval: 15s 33 | scrape_timeout: 10s 34 | metrics_path: / 35 | scheme: http 36 | static_configs: 37 | - targets: [ 'app:3031' ] -------------------------------------------------------------------------------- /deployments/kubernetes/config-map.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: config 5 | namespace: todo 6 | labels: 7 | app.kubernetes.io/name: todo 8 | app.kubernetes.io/component: backend 9 | app.kubernetes.io/version: v1 10 | data: 11 | # Application configuration 12 | ENVIRONMENT: "production" 13 | APPLICATION_NAME: "todo" 14 | APPLICATION_PORT: "3030" 15 | 16 | # Database configuration 17 | DATABASE_ENGINE: "mysql" 18 | DATABASE_HOST: "localhost" 19 | DATABASE_USER: "user" 20 | DATABASE_PASSWORD: "changeme" 21 | DATABASE_PORT: "3306" 22 | DATABASE_CHARSET: "utf8mb4" -------------------------------------------------------------------------------- /deployments/kubernetes/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: todo 5 | namespace: todo 6 | labels: 7 | app.kubernetes.io/name: todo 8 | app.kubernetes.io/component: backend 9 | app.kubernetes.io/version: v1 10 | spec: 11 | replicas: 1 12 | selector: 13 | matchLabels: 14 | app: todo 15 | template: 16 | metadata: 17 | labels: 18 | app: todo 19 | spec: 20 | containers: 21 | - name: app 22 | image: ghcr.io/marcoshuck/todo/app:latest 23 | ports: 24 | - containerPort: 3030 25 | envFrom: 26 | - configMapRef: 27 | name: config 28 | securityContext: 29 | allowPrivilegeEscalation: false 30 | runAsNonRoot: true 31 | resources: 32 | requests: 33 | memory: "64Mi" 34 | cpu: "250m" 35 | limits: 36 | memory: "128Mi" 37 | cpu: "500m" 38 | 39 | - name: gateway 40 | image: ghcr.io/marcoshuck/todo/gateway:latest 41 | ports: 42 | - containerPort: 8080 43 | env: 44 | - name: HOST 45 | value: "localhost" 46 | - name: PORT 47 | value: "8080" 48 | securityContext: 49 | allowPrivilegeEscalation: false 50 | runAsNonRoot: true 51 | resources: 52 | requests: 53 | memory: "64Mi" 54 | cpu: "250m" 55 | limits: 56 | memory: "128Mi" 57 | cpu: "500m" 58 | -------------------------------------------------------------------------------- /deployments/kubernetes/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: todo -------------------------------------------------------------------------------- /deployments/kubernetes/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: todo 5 | namespace: todo 6 | labels: 7 | app.kubernetes.io/name: todo 8 | app.kubernetes.io/component: backend 9 | app.kubernetes.io/version: v1 10 | spec: 11 | selector: 12 | app.kubernetes.io/name: todo 13 | app.kubernetes.io/version: v1 14 | ports: 15 | - protocol: TCP 16 | port: 80 17 | targetPort: 8080 -------------------------------------------------------------------------------- /deployments/local/.env.ci: -------------------------------------------------------------------------------- 1 | # Application configuration 2 | ENVIRONMENT="staging" 3 | APPLICATION_NAME="todo" 4 | APPLICATION_PORT=3030 5 | 6 | # Gateway configuration 7 | SERVER_ADDRESS="app:3030" 8 | 9 | # Database configuration 10 | DATABASE_ENGINE="mysql" 11 | DATABASE_NAME="todo" 12 | DATABASE_HOST="db" 13 | DATABASE_USER="root" 14 | DATABASE_PASSWORD="changeme" 15 | DATABASE_PORT=3306 16 | DATABASE_CHARSET="utf8mb4" 17 | 18 | # MySQL configuration 19 | MYSQL_DATABASE="todo" 20 | MYSQL_ROOT_PASSWORD="changeme" 21 | 22 | # Telemetry 23 | ## Tracing 24 | TRACING_ENABLED="false" 25 | TRACING_HOST="localhost" 26 | TRACING_PORT="4317" 27 | 28 | ## Metrics 29 | METRICS_ENABLED="false" 30 | METRICS_HOST="localhost" 31 | METRICS_PORT="4317" -------------------------------------------------------------------------------- /deployments/local/ci.docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: "ghcr.io/marcoshuck/todo/app:latest" 4 | build: 5 | context: ../.. 6 | dockerfile: build/app.Dockerfile 7 | container_name: app 8 | ports: 9 | - "3030:3030" 10 | - "3031:3031" 11 | restart: unless-stopped 12 | env_file: 13 | - .env.ci 14 | networks: 15 | - transport 16 | - persistence 17 | depends_on: 18 | - db 19 | gateway: 20 | image: "ghcr.io/marcoshuck/todo/gateway:latest" 21 | build: 22 | context: ../.. 23 | dockerfile: build/gateway.Dockerfile 24 | container_name: gateway 25 | ports: 26 | - "8080:8080" 27 | restart: unless-stopped 28 | env_file: 29 | - .env.ci 30 | environment: 31 | - APPLICATION_PORT=8080 32 | networks: 33 | - transport 34 | depends_on: 35 | - app 36 | db: 37 | image: mysql:8 38 | container_name: db 39 | ports: 40 | - "3306:3306" 41 | restart: unless-stopped 42 | env_file: 43 | - .env.ci 44 | volumes: 45 | - db_data:/var/lib/mysql 46 | networks: 47 | - persistence 48 | 49 | networks: 50 | transport: 51 | name: "transport" 52 | persistence: 53 | name: "persistence" 54 | volumes: 55 | db_data: -------------------------------------------------------------------------------- /deployments/local/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: "marcoshuck/todo-app" 4 | build: 5 | context: ../.. 6 | dockerfile: build/app.Dockerfile 7 | container_name: app 8 | ports: 9 | - "3030:3030" 10 | - "3031:3031" 11 | restart: always 12 | env_file: 13 | - .env.common 14 | - .env.app 15 | networks: 16 | - transport 17 | - persistence 18 | - monitoring 19 | - events 20 | depends_on: 21 | - collector 22 | - db 23 | - events 24 | 25 | gateway: 26 | image: "marcoshuck/todo-gw" 27 | build: 28 | context: ../.. 29 | dockerfile: build/gateway.Dockerfile 30 | container_name: gateway 31 | ports: 32 | - "8080:8080" 33 | restart: always 34 | env_file: 35 | - .env.common 36 | - .env.gateway 37 | networks: 38 | - transport 39 | - monitoring 40 | depends_on: 41 | - app 42 | - collector 43 | 44 | db: 45 | image: mysql:8 46 | container_name: db 47 | ports: 48 | - "3306:3306" 49 | restart: unless-stopped 50 | environment: 51 | MYSQL_ROOT_PASSWORD: "root" 52 | MYSQL_DATABASE: "todo" 53 | volumes: 54 | - db_data:/var/lib/mysql 55 | networks: 56 | - persistence 57 | 58 | jaeger: 59 | image: jaegertracing/all-in-one:latest 60 | container_name: jaeger 61 | ports: 62 | - "6831:6831" 63 | - "6832:6832" 64 | - "5778:5778" 65 | - "16686:16686" 66 | - "4318:4318" 67 | - "14250:14250" 68 | - "14268:14268" 69 | - "14269:14269" 70 | - "9411:9411" 71 | environment: 72 | - LOG_LEVEL=debug 73 | - COLLECTOR_OTLP_ENABLED=true 74 | networks: 75 | - monitoring 76 | 77 | prometheus: 78 | image: prom/prometheus 79 | container_name: prometheus 80 | command: 81 | - '--config.file=/etc/prometheus/prometheus.yml' 82 | ports: 83 | - "9090:9090" 84 | restart: unless-stopped 85 | volumes: 86 | - ../../configs/prometheus:/etc/prometheus 87 | - prometheus_data:/prometheus 88 | networks: 89 | - monitoring 90 | 91 | grafana: 92 | image: grafana/grafana 93 | container_name: grafana 94 | ports: 95 | - "3000:3000" 96 | restart: unless-stopped 97 | environment: 98 | - GF_SECURITY_ADMIN_USER=admin 99 | - GF_SECURITY_ADMIN_PASSWORD=grafana 100 | volumes: 101 | - ../../configs/grafana:/etc/grafana/provisioning/datasources 102 | networks: 103 | - monitoring 104 | 105 | collector: 106 | image: otel/opentelemetry-collector:0.88.0 107 | container_name: collector 108 | command: [ "--config=/etc/otel/config.yaml" ] 109 | volumes: 110 | - ../../configs/otel_collector:/etc/otel/ 111 | ports: 112 | - "1888:1888" # pprof extension 113 | - "8888:8888" # Prometheus metrics exposed by the collector 114 | - "8889:8889" # Prometheus exporter metrics 115 | - "13133:13133" # health_check extension 116 | - "55679:55679" # zpages extension 117 | - "4317:4317" # otlp receiver 118 | networks: 119 | - monitoring 120 | depends_on: 121 | - jaeger 122 | - prometheus 123 | 124 | events: 125 | container_name: events 126 | image: nats:2.10.18 127 | command: 128 | - "--name=nats" 129 | - "--cluster_name=events" 130 | - "--cluster=nats://events:6222" 131 | - "--routes=nats-route://events:6222" 132 | - "--http_port=8222" 133 | - "--js" 134 | - "--sd=/data" 135 | ports: 136 | - 8222:8222 137 | volumes: 138 | - events_data:/data 139 | networks: 140 | - events 141 | 142 | networks: 143 | transport: 144 | name: "transport" 145 | persistence: 146 | name: "persistence" 147 | monitoring: 148 | name: "monitoring" 149 | events: 150 | name: "events" 151 | volumes: 152 | db_data: 153 | prometheus_data: 154 | events_data: -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/marcoshuck/todo 2 | 3 | go 1.22 4 | 5 | require ( 6 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.2-20240508200655-46a4cf4ba109.2 7 | dagger.io/dagger v0.11.4 8 | github.com/bufbuild/protovalidate-go v0.6.3 9 | github.com/caarlos0/env/v9 v9.0.0 10 | github.com/go-chi/chi/v5 v5.1.0 11 | github.com/go-chi/cors v1.2.1 12 | github.com/gojaguar/jaguar v0.6.1 13 | github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 14 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0 15 | github.com/mennanov/fieldmask-utils v1.1.2 16 | github.com/prometheus/client_golang v1.19.1 17 | github.com/stretchr/testify v1.9.0 18 | github.com/uptrace/opentelemetry-go-extra/otelgorm v0.3.1 19 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 20 | go.opentelemetry.io/otel v1.28.0 21 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 22 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 23 | go.opentelemetry.io/otel/metric v1.28.0 24 | go.opentelemetry.io/otel/sdk v1.28.0 25 | go.opentelemetry.io/otel/sdk/metric v1.28.0 26 | go.opentelemetry.io/otel/trace v1.28.0 27 | go.uber.org/multierr v1.11.0 28 | go.uber.org/zap v1.27.0 29 | google.golang.org/genproto/googleapis/api v0.0.0-20240723171418-e6d459c13d2a 30 | google.golang.org/grpc v1.65.0 31 | google.golang.org/protobuf v1.34.2 32 | gorm.io/driver/sqlite v1.5.6 33 | gorm.io/gorm v1.25.11 34 | ) 35 | 36 | require ( 37 | github.com/99designs/gqlgen v0.17.44 // indirect 38 | github.com/Khan/genqlient v0.7.0 // indirect 39 | github.com/adrg/xdg v0.4.0 // indirect 40 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 41 | github.com/beorn7/perks v1.0.1 // indirect 42 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 43 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 44 | github.com/davecgh/go-spew v1.1.1 // indirect 45 | github.com/go-logr/logr v1.4.2 // indirect 46 | github.com/go-logr/stdr v1.2.2 // indirect 47 | github.com/go-sql-driver/mysql v1.7.0 // indirect 48 | github.com/google/cel-go v0.20.1 // indirect 49 | github.com/google/uuid v1.6.0 // indirect 50 | github.com/jackc/pgpassfile v1.0.0 // indirect 51 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 52 | github.com/jackc/pgx/v5 v5.5.4 // indirect 53 | github.com/jackc/puddle/v2 v2.2.1 // indirect 54 | github.com/jinzhu/inflection v1.0.0 // indirect 55 | github.com/jinzhu/now v1.1.5 // indirect 56 | github.com/mattn/go-sqlite3 v1.14.22 // indirect 57 | github.com/mitchellh/go-homedir v1.1.0 // indirect 58 | github.com/pkg/errors v0.9.1 // indirect 59 | github.com/pmezard/go-difflib v1.0.0 // indirect 60 | github.com/prometheus/client_model v0.5.0 // indirect 61 | github.com/prometheus/common v0.48.0 // indirect 62 | github.com/prometheus/procfs v0.12.0 // indirect 63 | github.com/sosodev/duration v1.2.0 // indirect 64 | github.com/stoewer/go-strcase v1.3.0 // indirect 65 | github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.1 // indirect 66 | github.com/vektah/gqlparser/v2 v2.5.15 // indirect 67 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect 68 | go.opentelemetry.io/proto/otlp v1.3.1 // indirect 69 | golang.org/x/crypto v0.24.0 // indirect 70 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect 71 | golang.org/x/net v0.26.0 // indirect 72 | golang.org/x/sync v0.7.0 // indirect 73 | golang.org/x/sys v0.21.0 // indirect 74 | golang.org/x/text v0.16.0 // indirect 75 | google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect 76 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a // indirect 77 | gopkg.in/yaml.v3 v3.0.1 // indirect 78 | gorm.io/driver/mysql v1.5.0 // indirect 79 | gorm.io/driver/postgres v1.5.0 // indirect 80 | ) 81 | -------------------------------------------------------------------------------- /internal/conf/config.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "fmt" 5 | "github.com/caarlos0/env/v9" 6 | "github.com/gojaguar/jaguar/config" 7 | ) 8 | 9 | type Metrics struct { 10 | Enabled bool `env:"ENABLED" envDefault:"true"` 11 | Host string `env:"HOST" envDefault:"localhost"` 12 | Port uint16 `env:"PORT" envDefault:"4317"` 13 | } 14 | 15 | func (m Metrics) Address() string { 16 | return fmt.Sprintf("%s:%d", m.Host, m.Port) 17 | } 18 | 19 | type Tracing struct { 20 | Enabled bool `env:"ENABLED" envDefault:"true"` 21 | Host string `env:"HOST" envDefault:"localhost"` 22 | Port uint16 `env:"PORT" envDefault:"4317"` 23 | } 24 | 25 | func (t Tracing) Address() string { 26 | return fmt.Sprintf("%s:%d", t.Host, t.Port) 27 | } 28 | 29 | // ServerConfig holds the configuration for gRPC servers. It currently uses env vars, but it can eventually 30 | // migrate to a different config provider. 31 | type ServerConfig struct { 32 | config.Config 33 | DB config.Database `envPrefix:"DATABASE_"` 34 | Metrics Metrics `envPrefix:"METRICS_"` 35 | Tracing Tracing `envPrefix:"TRACING_"` 36 | } 37 | 38 | // Listener returns the configuration needed to initialize a net.Listener instance. 39 | func (c ServerConfig) Listener() (network string, address string) { 40 | return "tcp", fmt.Sprintf(":%d", c.Port) 41 | } 42 | 43 | // ReadServerConfig reads the ServerConfig from environment variables. 44 | func ReadServerConfig() (ServerConfig, error) { 45 | var cfg ServerConfig 46 | err := env.Parse(&cfg) 47 | if err != nil { 48 | return ServerConfig{}, err 49 | } 50 | return cfg, nil 51 | } 52 | 53 | // GatewayConfig holds the configuration for gRPC clients. It currently uses env vars, but it can eventually 54 | // migrate to a different config provider. 55 | type GatewayConfig struct { 56 | config.Config 57 | ServerAddress string `env:"SERVER_ADDRESS"` 58 | Metrics Metrics `envPrefix:"METRICS_"` 59 | Tracing Tracing `envPrefix:"TRACING_"` 60 | } 61 | 62 | // ReadGatewayConfig reads the GatewayConfig from environment variables. 63 | func ReadGatewayConfig() (GatewayConfig, error) { 64 | var cfg GatewayConfig 65 | err := env.Parse(&cfg) 66 | if err != nil { 67 | return GatewayConfig{}, err 68 | } 69 | return cfg, nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/conf/config_test.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestConfig_Default(t *testing.T) { 11 | err, cancel := setEnv("APPLICATION_NAME", "todo") 12 | require.NoError(t, err) 13 | defer cancel() 14 | 15 | err, cancel = setEnv("DATABASE_NAME", "todo_db") 16 | require.NoError(t, err) 17 | defer cancel() 18 | 19 | cfg, err := ReadServerConfig() 20 | assert.NoError(t, err) 21 | assert.NotZero(t, cfg) 22 | assert.Equal(t, 3030, cfg.Port) 23 | assert.Equal(t, "todo", cfg.Name) 24 | assert.Equal(t, "todo_db", cfg.DB.Name) 25 | } 26 | 27 | func setEnv(key string, value string) (error, func()) { 28 | return os.Setenv(key, value), func() { 29 | _ = os.Unsetenv(key) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/domain/migration.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "gorm.io/gorm" 4 | 5 | // MigrateModels migrates the domain models using the given DB connection. 6 | func MigrateModels(db *gorm.DB) error { 7 | return db.Migrator().AutoMigrate( 8 | &Task{}, 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /internal/domain/task.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "errors" 5 | "github.com/gojaguar/jaguar/strings" 6 | tasksv1 "github.com/marcoshuck/todo/api/tasks/v1" 7 | "github.com/marcoshuck/todo/internal/serializer" 8 | fieldmask_utils "github.com/mennanov/fieldmask-utils" 9 | "google.golang.org/protobuf/types/known/fieldmaskpb" 10 | "google.golang.org/protobuf/types/known/timestamppb" 11 | "gorm.io/gorm" 12 | "time" 13 | ) 14 | 15 | var _ serializer.API[*tasksv1.Task] = (*Task)(nil) 16 | 17 | // Task defines the scope of an action a User implements in their tasks dashboard. 18 | type Task struct { 19 | gorm.Model 20 | Title string `json:"title" validate:"required,min=3"` 21 | Description string `json:"description"` 22 | Deadline *time.Time `json:"deadline"` 23 | CompletedAt *time.Time `json:"completed_at"` 24 | } 25 | 26 | // API converts this Task to a tasksv1.Task. 27 | func (t *Task) API() *tasksv1.Task { 28 | var deadline *timestamppb.Timestamp 29 | if t.Deadline != nil { 30 | deadline = timestamppb.New(*t.Deadline) 31 | } 32 | var completedAt *timestamppb.Timestamp 33 | if t.CompletedAt != nil { 34 | completedAt = timestamppb.New(*t.CompletedAt) 35 | } 36 | return &tasksv1.Task{ 37 | Id: int64(t.ID), 38 | Title: t.Title, 39 | Description: t.Description, 40 | Deadline: deadline, 41 | CompletedAt: completedAt, 42 | CreateTime: timestamppb.New(t.CreatedAt), 43 | UpdateTime: timestamppb.New(t.UpdatedAt), 44 | } 45 | } 46 | 47 | // FromAPI fills this Task with the data from tasksv1.Task. 48 | func (t *Task) FromAPI(in *tasksv1.Task) { 49 | t.ID = uint(in.GetId()) 50 | t.Title = in.GetTitle() 51 | t.Description = in.GetDescription() 52 | if in.GetDeadline() != nil { 53 | d := in.GetDeadline().AsTime() 54 | t.Deadline = &d 55 | } 56 | if in.GetCompletedAt() != nil { 57 | at := in.GetDeadline().AsTime() 58 | t.CompletedAt = &at 59 | } 60 | t.CreatedAt = in.GetCreateTime().AsTime() 61 | t.UpdatedAt = in.GetUpdateTime().AsTime() 62 | } 63 | 64 | // ApplyMask returns the Map of the current Task with the given mask applied. 65 | func (t *Task) ApplyMask(mask *fieldmaskpb.FieldMask) (map[string]any, error) { 66 | mask.Normalize() 67 | if !mask.IsValid(t.API()) { 68 | return nil, errors.New("invalid mask") 69 | } 70 | protoMask, err := fieldmask_utils.MaskFromProtoFieldMask(mask, strings.PascalCase) 71 | if err != nil { 72 | return nil, err 73 | } 74 | m := make(map[string]any) 75 | if err = fieldmask_utils.StructToMap(protoMask, t.API(), m); err != nil { 76 | return nil, err 77 | } 78 | return m, nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/gateway/gateway.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 5 | "github.com/marcoshuck/todo/internal/conf" 6 | "github.com/marcoshuck/todo/internal/telemetry" 7 | "net/http" 8 | ) 9 | 10 | type Gateway struct { 11 | Telemeter telemetry.Telemetry 12 | mux *runtime.ServeMux 13 | handler http.Handler 14 | Config conf.GatewayConfig 15 | } 16 | -------------------------------------------------------------------------------- /internal/gateway/runner.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "fmt" 5 | "go.uber.org/zap" 6 | "net/http" 7 | ) 8 | 9 | func Run(gw Gateway) error { 10 | addr := fmt.Sprintf(":%d", gw.Config.Port) 11 | gw.Telemeter.Logger.Info("Listening...", zap.String("address", addr)) 12 | if err := http.ListenAndServe(addr, gw.handler); err != nil { 13 | return err 14 | } 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /internal/gateway/setup.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "context" 5 | "github.com/go-chi/chi/v5" 6 | "github.com/go-chi/chi/v5/middleware" 7 | "github.com/go-chi/cors" 8 | "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 9 | "github.com/marcoshuck/todo/api/tasks/v1" 10 | "github.com/marcoshuck/todo/internal/conf" 11 | "github.com/marcoshuck/todo/internal/interceptors" 12 | "github.com/marcoshuck/todo/internal/telemetry" 13 | "github.com/prometheus/client_golang/prometheus/promhttp" 14 | "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" 15 | "google.golang.org/grpc" 16 | "google.golang.org/grpc/credentials/insecure" 17 | "log" 18 | "time" 19 | ) 20 | 21 | func Setup(ctx context.Context, cfg conf.GatewayConfig) (Gateway, error) { 22 | telemeter, err := telemetry.SetupTelemetry(cfg.Config, cfg.Tracing, cfg.Metrics) 23 | if err != nil { 24 | log.Fatalln("Failed to initialize telemetry:", err) 25 | } 26 | 27 | mux := runtime.NewServeMux() 28 | 29 | opts := []grpc.DialOption{ 30 | grpc.WithTransportCredentials(insecure.NewCredentials()), 31 | interceptors.NewClientUnaryInterceptors(telemeter), 32 | interceptors.NewClientStreamInterceptors(telemeter), 33 | grpc.WithStatsHandler( 34 | otelgrpc.NewClientHandler( 35 | otelgrpc.WithTracerProvider(telemeter.TracerProvider), 36 | otelgrpc.WithMeterProvider(telemeter.MeterProvider), 37 | otelgrpc.WithPropagators(telemeter.Propagator), 38 | ), 39 | ), 40 | } 41 | err = tasksv1.RegisterTasksWriterServiceHandlerFromEndpoint(ctx, mux, cfg.ServerAddress, opts) 42 | if err != nil { 43 | log.Fatalln("Failed to register tasks service:", err) 44 | } 45 | err = tasksv1.RegisterTasksReaderServiceHandlerFromEndpoint(ctx, mux, cfg.ServerAddress, opts) 46 | if err != nil { 47 | log.Fatalln("Failed to register tasks service:", err) 48 | } 49 | r := chi.NewRouter() 50 | r.Use(middleware.RequestID) 51 | r.Use(middleware.RealIP) 52 | r.Use(middleware.Logger) 53 | r.Use(middleware.Recoverer) 54 | r.Use(middleware.Timeout(60 * time.Second)) 55 | r.Use(middleware.Heartbeat("/livez")) 56 | r.Use(cors.Handler(cors.Options{ 57 | // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts 58 | AllowedOrigins: []string{"https://*", "http://*"}, 59 | // AllowOriginFunc: func(r *http.Request, origin string) bool { return true }, 60 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 61 | AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, 62 | ExposedHeaders: []string{"Link"}, 63 | AllowCredentials: false, 64 | MaxAge: 300, // Maximum value not ignored by any of major browsers 65 | })) 66 | r.Mount("/metrics", promhttp.Handler()) 67 | r.Mount("/", mux) 68 | 69 | return Gateway{ 70 | Telemeter: telemeter, 71 | mux: mux, 72 | handler: r, 73 | Config: cfg, 74 | }, nil 75 | } 76 | -------------------------------------------------------------------------------- /internal/interceptors/client.go: -------------------------------------------------------------------------------- 1 | package interceptors 2 | 3 | import ( 4 | grpc_logging "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging" 5 | "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/retry" 6 | "github.com/marcoshuck/todo/internal/telemetry" 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/codes" 9 | "time" 10 | ) 11 | 12 | func NewClientUnaryInterceptors(telemeter telemetry.Telemetry) grpc.DialOption { 13 | return grpc.WithChainUnaryInterceptor( 14 | grpc_logging.UnaryClientInterceptor(interceptorLogger(telemeter.Logger)), 15 | retry.UnaryClientInterceptor( 16 | retry.WithCodes(codes.ResourceExhausted, codes.Unavailable), 17 | retry.WithMax(10), 18 | retry.WithBackoff(retry.BackoffExponential(50*time.Millisecond)), 19 | ), 20 | ) 21 | } 22 | 23 | func NewClientStreamInterceptors(telemeter telemetry.Telemetry) grpc.DialOption { 24 | return grpc.WithChainStreamInterceptor( 25 | grpc_logging.StreamClientInterceptor(interceptorLogger(telemeter.Logger)), 26 | retry.StreamClientInterceptor( 27 | retry.WithCodes(codes.ResourceExhausted, codes.Unavailable), 28 | retry.WithMax(10), 29 | retry.WithBackoff(retry.BackoffExponential(50*time.Millisecond)), 30 | ), 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /internal/interceptors/common.go: -------------------------------------------------------------------------------- 1 | package interceptors 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging" 7 | grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" 8 | "go.uber.org/zap" 9 | "google.golang.org/grpc/codes" 10 | "google.golang.org/grpc/status" 11 | ) 12 | 13 | func interceptorLogger(l *zap.Logger) logging.Logger { 14 | return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) { 15 | f := make([]zap.Field, 0, len(fields)/2) 16 | 17 | for i := 0; i < len(fields); i += 2 { 18 | key := fields[i] 19 | value := fields[i+1] 20 | 21 | switch v := value.(type) { 22 | case string: 23 | f = append(f, zap.String(key.(string), v)) 24 | case int: 25 | f = append(f, zap.Int(key.(string), v)) 26 | case bool: 27 | f = append(f, zap.Bool(key.(string), v)) 28 | default: 29 | f = append(f, zap.Any(key.(string), v)) 30 | } 31 | } 32 | 33 | logger := l.WithOptions(zap.AddCallerSkip(1)).With(f...) 34 | 35 | switch lvl { 36 | case logging.LevelDebug: 37 | logger.Debug(msg) 38 | case logging.LevelInfo: 39 | logger.Info(msg) 40 | case logging.LevelWarn: 41 | logger.Warn(msg) 42 | case logging.LevelError: 43 | logger.Error(msg) 44 | default: 45 | panic(fmt.Sprintf("unknown level %v", lvl)) 46 | } 47 | }) 48 | } 49 | 50 | func RecoveryHandler(logger *zap.Logger) grpc_recovery.RecoveryHandlerFunc { 51 | return func(p any) (err error) { 52 | logger.Error("recoverying from panic", zap.Any("panic", p)) 53 | return status.Errorf(codes.Unknown, "panic triggered: %v", p) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/interceptors/server.go: -------------------------------------------------------------------------------- 1 | package interceptors 2 | 3 | import ( 4 | "github.com/bufbuild/protovalidate-go" 5 | grpc_logging "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging" 6 | grpc_validate "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidate" 7 | grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" 8 | "github.com/marcoshuck/todo/internal/telemetry" 9 | "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" 10 | "google.golang.org/grpc" 11 | ) 12 | 13 | func NewServerInterceptors(telemeter telemetry.Telemetry, validator *protovalidate.Validator) []grpc.ServerOption { 14 | var opts []grpc.ServerOption 15 | return append(opts, 16 | newServerUnaryInterceptors(telemeter, validator), 17 | newServerStreamInterceptors(telemeter, validator), 18 | grpc.StatsHandler( 19 | otelgrpc.NewServerHandler( 20 | otelgrpc.WithTracerProvider(telemeter.TracerProvider), 21 | otelgrpc.WithMeterProvider(telemeter.MeterProvider), 22 | otelgrpc.WithPropagators(telemeter.Propagator), 23 | ), 24 | ), 25 | ) 26 | } 27 | 28 | func newServerUnaryInterceptors(telemeter telemetry.Telemetry, validator *protovalidate.Validator) grpc.ServerOption { 29 | var interceptors []grpc.UnaryServerInterceptor 30 | 31 | if validator != nil { 32 | interceptors = append(interceptors, grpc_validate.UnaryServerInterceptor(validator)) 33 | } 34 | 35 | if telemeter.Logger != nil { 36 | interceptors = append(interceptors, 37 | grpc_logging.UnaryServerInterceptor(interceptorLogger(telemeter.Logger)), 38 | grpc_recovery.UnaryServerInterceptor(grpc_recovery.WithRecoveryHandler(RecoveryHandler(telemeter.Logger))), 39 | ) 40 | } 41 | 42 | return grpc.ChainUnaryInterceptor(interceptors...) 43 | } 44 | 45 | func newServerStreamInterceptors(telemeter telemetry.Telemetry, validator *protovalidate.Validator) grpc.ServerOption { 46 | var interceptors []grpc.StreamServerInterceptor 47 | 48 | if validator != nil { 49 | interceptors = append(interceptors, grpc_validate.StreamServerInterceptor(validator)) 50 | } 51 | 52 | if telemeter.Logger != nil { 53 | interceptors = append(interceptors, 54 | grpc_logging.StreamServerInterceptor(interceptorLogger(telemeter.Logger)), 55 | grpc_recovery.StreamServerInterceptor(grpc_recovery.WithRecoveryHandler(RecoveryHandler(telemeter.Logger))), 56 | ) 57 | } 58 | 59 | return grpc.ChainStreamInterceptor(interceptors...) 60 | } 61 | -------------------------------------------------------------------------------- /internal/serializer/page_token.go: -------------------------------------------------------------------------------- 1 | package serializer 2 | 3 | import ( 4 | "encoding/base64" 5 | "time" 6 | ) 7 | 8 | // EncodePageToken converts the given timestamp into a base64-encoded page token. 9 | func EncodePageToken(t time.Time) string { 10 | return base64.URLEncoding.EncodeToString([]byte(t.Format(time.RFC3339))) 11 | } 12 | 13 | // DecodePageToken converts the given token into a valid timestamp. 14 | // It returns an error if the token is not a valid representation of a base64-encoded 15 | // timestamp. 16 | func DecodePageToken(token string) (time.Time, error) { 17 | b, err := base64.URLEncoding.DecodeString(token) 18 | if err != nil { 19 | return time.Time{}, err 20 | } 21 | return time.Parse(time.RFC3339, string(b)) 22 | } 23 | -------------------------------------------------------------------------------- /internal/serializer/page_token_test.go: -------------------------------------------------------------------------------- 1 | package serializer 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestEncodePageToken(t *testing.T) { 10 | now := time.Now() 11 | token := EncodePageToken(now) 12 | assert.NotNil(t, token) 13 | var expected string 14 | assert.IsType(t, expected, token) 15 | assert.NotEmpty(t, token) 16 | } 17 | 18 | func TestDecodePageToken(t *testing.T) { 19 | now := time.Now() 20 | token := EncodePageToken(now) 21 | 22 | out, err := DecodePageToken(token) 23 | assert.NoError(t, err) 24 | assert.Equal(t, now.Year(), out.Year()) 25 | assert.Equal(t, now.Month(), out.Month()) 26 | assert.Equal(t, now.Day(), out.Day()) 27 | assert.Equal(t, now.Hour(), out.Hour()) 28 | assert.Equal(t, now.Minute(), out.Minute()) 29 | assert.Equal(t, now.Second(), out.Second()) 30 | } 31 | 32 | func TestDecodePageToken_InvalidToken(t *testing.T) { 33 | out, err := DecodePageToken("1234") 34 | assert.Error(t, err) 35 | assert.Zero(t, out) 36 | } 37 | -------------------------------------------------------------------------------- /internal/serializer/serializer.go: -------------------------------------------------------------------------------- 1 | package serializer 2 | 3 | type Format string 4 | 5 | const ( 6 | FormatJSON Format = "json" 7 | FormatYAML Format = "yaml" 8 | ) 9 | 10 | // JSON holds a method to convert an underlying implementation to JSON format. 11 | type JSON interface { 12 | JSON() ([]byte, error) 13 | FromJSON(data []byte) error 14 | } 15 | 16 | // YAML holds a method to convert an underlying implementation to YAML format. 17 | type YAML interface { 18 | YAML() ([]byte, error) 19 | FromYAML(data []byte) error 20 | } 21 | 22 | // API holds a method to convert an underlying implementation to its API counterpart. 23 | // This method uses generics to support different API entities. 24 | type API[T any] interface { 25 | API() T 26 | FromAPI(in T) 27 | } 28 | -------------------------------------------------------------------------------- /internal/server/application.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | tasksv1 "github.com/marcoshuck/todo/api/tasks/v1" 6 | "github.com/marcoshuck/todo/internal/conf" 7 | "go.opentelemetry.io/otel/metric" 8 | "go.opentelemetry.io/otel/trace" 9 | "go.uber.org/multierr" 10 | "go.uber.org/zap" 11 | "google.golang.org/grpc/health" 12 | healthv1 "google.golang.org/grpc/health/grpc_health_v1" 13 | "gorm.io/gorm" 14 | "io" 15 | "net" 16 | "net/http" 17 | "time" 18 | ) 19 | 20 | // Services groups all the services exposed by a single gRPC Server. 21 | type Services struct { 22 | TasksWriter tasksv1.TasksWriterServiceServer 23 | TasksReader tasksv1.TasksReaderServiceServer 24 | Health *health.Server 25 | } 26 | 27 | // shutDowner holds a method to gracefully shut down a service or integration. 28 | type shutDowner interface { 29 | // Shutdown releases any held computational resources. 30 | Shutdown(ctx context.Context) error 31 | } 32 | 33 | // grpcServer holds the method to serve a gRPC server using a net.Listener. 34 | type grpcServer interface { 35 | // Serve serves a gRPC server through net.Listener until an error occurs. 36 | Serve(net.Listener) error 37 | } 38 | 39 | // Application abstracts all the functional components to be run by the server. 40 | type Application struct { 41 | server grpcServer 42 | listener net.Listener 43 | logger *zap.Logger 44 | db *gorm.DB 45 | services Services 46 | tracerProvider trace.TracerProvider 47 | meterProvider metric.MeterProvider 48 | shutdown []shutDowner 49 | closer []io.Closer 50 | cfg conf.ServerConfig 51 | metricsServer *http.Server 52 | } 53 | 54 | // Run serves the application services. 55 | func (app Application) Run(ctx context.Context) error { 56 | go app.checkHealth(ctx) 57 | go app.serveMetrics() 58 | 59 | app.logger.Info("Running application") 60 | return app.server.Serve(app.listener) 61 | } 62 | 63 | // Shutdown releases any held resources by dependencies of this Application. 64 | func (app Application) Shutdown(ctx context.Context) error { 65 | var err error 66 | for _, downer := range app.shutdown { 67 | if downer == nil { 68 | continue 69 | } 70 | err = multierr.Append(err, downer.Shutdown(ctx)) 71 | } 72 | for _, closer := range app.closer { 73 | if closer == nil { 74 | continue 75 | } 76 | err = multierr.Append(err, closer.Close()) 77 | } 78 | return err 79 | } 80 | 81 | func (app Application) checkHealth(ctx context.Context) { 82 | app.logger.Info("Running health service") 83 | for { 84 | if ctx.Err() != nil { 85 | return 86 | } 87 | app.services.Health.SetServingStatus("app.db", app.checkDatabaseHealth()) 88 | time.Sleep(10 * time.Second) 89 | } 90 | } 91 | 92 | func (app Application) checkDatabaseHealth() healthv1.HealthCheckResponse_ServingStatus { 93 | state := healthv1.HealthCheckResponse_SERVING 94 | db, err := app.db.DB() 95 | if err != nil { 96 | state = healthv1.HealthCheckResponse_NOT_SERVING 97 | } 98 | if err = db.Ping(); err != nil { 99 | state = healthv1.HealthCheckResponse_NOT_SERVING 100 | } 101 | return state 102 | } 103 | 104 | func (app Application) serveMetrics() { 105 | if err := app.metricsServer.ListenAndServe(); err != nil { 106 | app.logger.Error("failed to listen and server to metrics server", zap.Error(err)) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /internal/server/runner.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "go.uber.org/zap" 6 | ) 7 | 8 | // Run runs the given application. 9 | func Run(app Application) error { 10 | ctx, cancel := context.WithCancel(context.Background()) 11 | defer cancel() 12 | app.logger.Debug("Listening...", zap.String("address", app.listener.Addr().String())) 13 | if err := app.Run(ctx); err != nil { 14 | return err 15 | } 16 | return app.Shutdown(ctx) 17 | } 18 | -------------------------------------------------------------------------------- /internal/server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gojaguar/jaguar/config" 5 | "github.com/marcoshuck/todo/internal/conf" 6 | "github.com/stretchr/testify/suite" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | func TestServerSuite(t *testing.T) { 12 | suite.Run(t, new(ServerTestSuite)) 13 | } 14 | 15 | type ServerTestSuite struct { 16 | suite.Suite 17 | } 18 | 19 | func (suite *ServerTestSuite) SetupSuite() { 20 | suite.Require().NoError(os.Setenv("APPLICATION_NAME", "todo")) 21 | suite.Require().NoError(os.Setenv("DATABASE_NAME", "todo_db")) 22 | } 23 | 24 | func (suite *ServerTestSuite) SetupTest() { 25 | 26 | } 27 | 28 | func (suite *ServerTestSuite) TearDownTest() { 29 | 30 | } 31 | 32 | func (suite *ServerTestSuite) TearDownSuite() { 33 | suite.Require().NoError(os.Unsetenv("APPLICATION_NAME")) 34 | suite.Require().NoError(os.Unsetenv("DATABASE_NAME")) 35 | suite.Require().NoError(os.Remove("todo_db.db")) 36 | } 37 | 38 | func (suite *ServerTestSuite) TestSetup() { 39 | cfg, err := conf.ReadServerConfig() 40 | suite.Require().NoError(err) 41 | suite.Require().NotZero(cfg) 42 | 43 | cfg.DB.Engine = config.EngineSQLite 44 | 45 | app, err := Setup(cfg) 46 | suite.Assert().NoError(err) 47 | suite.Assert().NotZero(app) 48 | suite.Assert().NotNil(app.listener) 49 | suite.Assert().NotNil(app.logger) 50 | suite.Assert().NotNil(app.server) 51 | suite.Assert().NotNil(app.db) 52 | suite.Assert().NotNil(app.services.TasksWriter) 53 | } 54 | -------------------------------------------------------------------------------- /internal/server/setup.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/bufbuild/protovalidate-go" 7 | "github.com/gojaguar/jaguar/database" 8 | tasksv1 "github.com/marcoshuck/todo/api/tasks/v1" 9 | "github.com/marcoshuck/todo/internal/conf" 10 | "github.com/marcoshuck/todo/internal/domain" 11 | "github.com/marcoshuck/todo/internal/interceptors" 12 | "github.com/marcoshuck/todo/internal/service" 13 | "github.com/marcoshuck/todo/internal/telemetry" 14 | "github.com/prometheus/client_golang/prometheus/promhttp" 15 | "github.com/uptrace/opentelemetry-go-extra/otelgorm" 16 | "go.opentelemetry.io/otel/attribute" 17 | "go.opentelemetry.io/otel/metric" 18 | "go.opentelemetry.io/otel/trace" 19 | "go.uber.org/zap" 20 | "google.golang.org/grpc" 21 | "google.golang.org/grpc/health" 22 | healthv1 "google.golang.org/grpc/health/grpc_health_v1" 23 | "gorm.io/gorm" 24 | "io" 25 | "net" 26 | "net/http" 27 | ) 28 | 29 | // Setup creates a new application using the given ServerConfig. 30 | func Setup(cfg conf.ServerConfig) (Application, error) { 31 | 32 | telemeter, err := telemetry.SetupTelemetry(cfg.Config, cfg.Tracing, cfg.Metrics) 33 | if err != nil { 34 | return Application{}, err 35 | } 36 | 37 | telemeter.Logger.Debug("Initializing server", zap.String("server.name", cfg.Name), zap.String("server.environment", cfg.Environment)) 38 | 39 | db, dbConn, err := setupDB(cfg, telemeter.Logger, telemeter.TracerProvider) 40 | if err != nil { 41 | return Application{}, err 42 | } 43 | 44 | err = domain.MigrateModels(db) 45 | if err != nil { 46 | return Application{}, err 47 | } 48 | 49 | l, err := setupListener(cfg, telemeter.Logger) 50 | if err != nil { 51 | return Application{}, err 52 | } 53 | 54 | validator, err := protovalidate.New() 55 | if err != nil { 56 | return Application{}, err 57 | } 58 | 59 | srv := grpc.NewServer(interceptors.NewServerInterceptors(telemeter, validator)...) 60 | svc := setupServices(db, telemeter.Logger, telemeter.TracerProvider, telemeter.MeterProvider) 61 | registerServices(srv, svc) 62 | 63 | metricsServer := &http.Server{ 64 | Addr: fmt.Sprintf(":%d", cfg.Port+1), 65 | Handler: promhttp.Handler(), 66 | } 67 | 68 | return Application{ 69 | server: srv, 70 | listener: l, 71 | logger: telemeter.Logger, 72 | tracerProvider: telemeter.TracerProvider, 73 | meterProvider: telemeter.MeterProvider, 74 | db: db, 75 | services: svc, 76 | metricsServer: metricsServer, 77 | shutdown: []shutDowner{ 78 | telemeter.TraceExporter, 79 | telemeter.MeterExporter, 80 | }, 81 | closer: []io.Closer{ 82 | dbConn, 83 | metricsServer, 84 | }, 85 | cfg: cfg, 86 | }, nil 87 | } 88 | 89 | func registerServices(srv *grpc.Server, svc Services) { 90 | tasksv1.RegisterTasksWriterServiceServer(srv, svc.TasksWriter) 91 | tasksv1.RegisterTasksReaderServiceServer(srv, svc.TasksReader) 92 | healthv1.RegisterHealthServer(srv, svc.Health) 93 | } 94 | 95 | // setupServices initializes the Application Services. 96 | func setupServices(db *gorm.DB, logger *zap.Logger, tracerProvider trace.TracerProvider, meterProvider metric.MeterProvider) Services { 97 | logger.Debug("Initializing services") 98 | tasksWriterService := service.NewTasksWriter(db, logger, meterProvider.Meter("todo.huck.com.ar/tasks.writer")) 99 | tasksReaderService := service.NewTasksReader(db, logger, meterProvider.Meter("todo.huck.com.ar/tasks.reader")) 100 | healthService := health.NewServer() 101 | return Services{ 102 | TasksWriter: tasksWriterService, 103 | TasksReader: tasksReaderService, 104 | Health: healthService, 105 | } 106 | } 107 | 108 | // setupListener initializes a new tcp listener used by a gRPC server. 109 | func setupListener(cfg conf.ServerConfig, logger *zap.Logger) (net.Listener, error) { 110 | protocol, address := cfg.Listener() 111 | logger.Debug("Initializing listener", zap.String("listener.protocol", protocol), zap.String("listener.address", address)) 112 | l, err := net.Listen(protocol, address) 113 | if err != nil { 114 | logger.Error("Failed to initialize listener", zap.Error(err)) 115 | return nil, err 116 | } 117 | return l, nil 118 | } 119 | 120 | // setupDB initializes a new connection with a DB server. 121 | func setupDB(cfg conf.ServerConfig, logger *zap.Logger, provider trace.TracerProvider) (*gorm.DB, *sql.DB, error) { 122 | logger.Debug("Initializing DB connection", zap.String("db.engine", cfg.DB.Engine), zap.String("db.dsn", cfg.DB.DSN())) 123 | db, err := database.SetupConnectionSQL(cfg.DB) 124 | if err != nil { 125 | logger.Error("Failed to initialize DB connection", zap.Error(err)) 126 | return nil, nil, err 127 | } 128 | err = db.Use(otelgorm.NewPlugin( 129 | otelgorm.WithDBName(cfg.DB.Name), 130 | otelgorm.WithAttributes(attribute.String("db.engine", cfg.DB.Engine)), 131 | otelgorm.WithTracerProvider(provider), 132 | )) 133 | if err != nil { 134 | logger.Error("Failed to set up DB plugin", zap.Error(err)) 135 | return nil, nil, err 136 | } 137 | dbConn, err := db.DB() 138 | if err != nil { 139 | logger.Error("Failed get db connection", zap.Error(err)) 140 | return nil, nil, err 141 | } 142 | return db, dbConn, nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/service/tasks.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | tasksv1 "github.com/marcoshuck/todo/api/tasks/v1" 7 | "github.com/marcoshuck/todo/internal/domain" 8 | "github.com/marcoshuck/todo/internal/serializer" 9 | "go.opentelemetry.io/otel/metric" 10 | "go.opentelemetry.io/otel/trace" 11 | "go.uber.org/zap" 12 | "google.golang.org/grpc/codes" 13 | "google.golang.org/grpc/status" 14 | "gorm.io/gorm" 15 | "time" 16 | ) 17 | 18 | // tasks implements tasksv1.TasksWriterServiceServer. 19 | type tasks struct { 20 | tasksv1.UnimplementedTasksWriterServiceServer 21 | tasksv1.UnimplementedTasksReaderServiceServer 22 | db *gorm.DB 23 | logger *zap.Logger 24 | meter metric.Meter 25 | } 26 | 27 | // GetTask gets a task by ID. 28 | func (svc *tasks) GetTask(ctx context.Context, request *tasksv1.GetTaskRequest) (*tasksv1.Task, error) { 29 | svc.logger.Debug("Getting task by ID", zap.Int64("task.id", request.GetId())) 30 | span := trace.SpanFromContext(ctx) 31 | defer span.End() 32 | 33 | var task domain.Task 34 | span.AddEvent("Getting task from the database") 35 | err := svc.db.Model(&domain.Task{}).WithContext(ctx).First(&task, request.GetId()).Error 36 | if err != nil { 37 | svc.logger.Error("Failed to get task", zap.Error(err)) 38 | span.RecordError(err) 39 | if errors.Is(err, gorm.ErrRecordNotFound) { 40 | return nil, status.Error(codes.NotFound, "task not found") 41 | } 42 | return nil, status.Errorf(codes.Unavailable, "failed to get task: %v", err) 43 | } 44 | svc.logger.Debug("Returning task", zap.Int64("task.id", request.GetId()), zap.String("task.title", task.Title)) 45 | return task.API(), nil 46 | } 47 | 48 | // ListTasks lists tasks. 49 | func (svc *tasks) ListTasks(ctx context.Context, request *tasksv1.ListTasksRequest) (*tasksv1.ListTasksResponse, error) { 50 | svc.logger.Debug("Getting task list", zap.Int32("page_size", request.GetPageSize()), zap.String("page_token", request.GetPageToken())) 51 | span := trace.SpanFromContext(ctx) 52 | defer span.End() 53 | 54 | span.AddEvent("Getting tasks from the database") 55 | if request.GetPageSize() == 0 { 56 | request.PageSize = 50 57 | } 58 | 59 | query := svc.db.Model(&domain.Task{}).WithContext(ctx) 60 | if len(request.GetPageToken()) > 0 { 61 | updatedAt, err := serializer.DecodePageToken(request.GetPageToken()) 62 | if err == nil { 63 | svc.logger.Debug("Getting records older than page token", zap.Time("updated_at", updatedAt)) 64 | query = query.Where("updated_at < ?", updatedAt) 65 | } 66 | } 67 | query = query.Limit(int(request.GetPageSize() + 1)).Order("updated_at DESC") 68 | 69 | var out []domain.Task 70 | err := query.Find(&out).Error 71 | if err != nil { 72 | svc.logger.Error("Failed to list tasks", zap.Error(err)) 73 | span.RecordError(err) 74 | return nil, status.Errorf(codes.Unavailable, "failed to get task: %v", err) 75 | } 76 | 77 | var nextPageToken string 78 | if len(out) > int(request.GetPageSize()) { 79 | nextPageToken = serializer.EncodePageToken(out[request.GetPageSize()-1].UpdatedAt) 80 | svc.logger.Debug("Generating next page token", zap.Int32("page_size", request.GetPageSize()), zap.Int("count", len(out)), zap.String("page_token", nextPageToken), zap.Uint("last_element_id", out[request.GetPageSize()].ID)) 81 | out = out[:request.GetPageSize()] 82 | } 83 | 84 | svc.logger.Debug("Returning task list", zap.Int32("page_size", request.GetPageSize()), zap.String("page_token", request.GetPageToken()), zap.Int("count", len(out))) 85 | res := tasksv1.ListTasksResponse{ 86 | Tasks: make([]*tasksv1.Task, len(out)), 87 | NextPageToken: nextPageToken, 88 | } 89 | for i, task := range out { 90 | res.Tasks[i] = task.API() 91 | } 92 | return &res, nil 93 | } 94 | 95 | // CreateTask creates a Task. 96 | func (svc *tasks) CreateTask(ctx context.Context, request *tasksv1.CreateTaskRequest) (*tasksv1.Task, error) { 97 | svc.logger.Debug("Creating task", zap.String("task.title", request.GetTask().GetTitle())) 98 | span := trace.SpanFromContext(ctx) 99 | defer span.End() 100 | 101 | var task domain.Task 102 | svc.logger.Debug("Filling out task information") 103 | span.AddEvent("Parsing task from API request") 104 | task.FromAPI(request.GetTask()) 105 | now := time.Now() 106 | task.CreatedAt = now 107 | task.UpdatedAt = now 108 | span.AddEvent("Persisting task in the database") 109 | svc.logger.Debug("Persisting task in the database", zap.String("task.title", request.GetTask().GetTitle())) 110 | err := svc.db.Model(&domain.Task{}).WithContext(ctx).Create(&task).Error 111 | if err != nil { 112 | svc.logger.Error("Failed to create task", zap.Error(err)) 113 | span.RecordError(err) 114 | return nil, status.Error(codes.Unavailable, "failed to create task") 115 | } 116 | svc.logger.Debug("Returning created task", zap.String("task.title", request.GetTask().GetTitle())) 117 | return task.API(), nil 118 | } 119 | 120 | // DeleteTask deletes a task. 121 | func (svc *tasks) DeleteTask(ctx context.Context, request *tasksv1.DeleteTaskRequest) (*tasksv1.Task, error) { 122 | svc.logger.Debug("Deleting task", zap.Int64("task.id", request.GetId())) 123 | span := trace.SpanFromContext(ctx) 124 | defer span.End() 125 | task, err := svc.GetTask(ctx, &tasksv1.GetTaskRequest{Id: request.GetId()}) 126 | if err != nil { 127 | return nil, err 128 | } 129 | span.AddEvent("Deleting task from the database") 130 | err = svc.db.Model(&domain.Task{}).Delete(&domain.Task{}, task.GetId()).Error 131 | if err != nil { 132 | svc.logger.Error("Failed to delete task", zap.Error(err)) 133 | span.RecordError(err) 134 | return nil, status.Error(codes.Unavailable, "failed to delete task") 135 | } 136 | svc.logger.Debug("Task was deleted", zap.Int64("task.id", request.GetId())) 137 | return task, nil 138 | } 139 | 140 | // UndeleteTask undeletes a task. Opposite operation to DeleteTask. 141 | func (svc *tasks) UndeleteTask(ctx context.Context, request *tasksv1.UndeleteTaskRequest) (*tasksv1.Task, error) { 142 | svc.logger.Debug("Undeleting task", zap.Int64("task.id", request.GetId())) 143 | span := trace.SpanFromContext(ctx) 144 | defer span.End() 145 | err := svc.db.Model(&domain.Task{}).Unscoped().Where("id = ?", request.GetId()).Update("deleted_at", nil).Error 146 | if err != nil { 147 | svc.logger.Error("Failed to undelete task", zap.Error(err)) 148 | span.RecordError(err) 149 | return nil, status.Error(codes.Unavailable, "failed to undelete task") 150 | } 151 | task, err := svc.GetTask(ctx, &tasksv1.GetTaskRequest{Id: request.GetId()}) 152 | if err != nil { 153 | return nil, err 154 | } 155 | svc.logger.Debug("Task was undeleted", zap.Int64("task.id", request.GetId())) 156 | return task, nil 157 | } 158 | 159 | // UpdateTask updates a task. 160 | func (svc *tasks) UpdateTask(ctx context.Context, request *tasksv1.UpdateTaskRequest) (*tasksv1.Task, error) { 161 | svc.logger.Debug("Updating task", zap.Int64("task.id", request.GetTask().GetId())) 162 | 163 | span := trace.SpanFromContext(ctx) 164 | defer span.End() 165 | 166 | _, err := svc.GetTask(ctx, &tasksv1.GetTaskRequest{Id: request.GetTask().GetId()}) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | var task domain.Task 172 | task.FromAPI(request.GetTask()) 173 | m, err := task.ApplyMask(request.GetUpdateMask()) 174 | if err != nil { 175 | svc.logger.Error("Failed to apply update mask", zap.Error(err)) 176 | span.RecordError(err) 177 | return nil, status.Error(codes.Internal, "failed to generate update mask") 178 | } 179 | err = svc.db.Model(&domain.Task{}).Where("id = ?", request.GetTask().GetId()).Updates(m).Error 180 | if err != nil { 181 | svc.logger.Error("Failed to update task", zap.Error(err)) 182 | span.RecordError(err) 183 | return nil, status.Error(codes.Internal, "failed to update task") 184 | } 185 | svc.logger.Debug("Task was updated", zap.Int64("task.id", request.GetTask().GetId())) 186 | return svc.GetTask(ctx, &tasksv1.GetTaskRequest{Id: request.GetTask().GetId()}) 187 | } 188 | 189 | // NewTasksWriter initializes a new tasksv1.TasksWriterServiceServer implementation. 190 | func NewTasksWriter(db *gorm.DB, logger *zap.Logger, meter metric.Meter) tasksv1.TasksWriterServiceServer { 191 | tasksLogger := logger.Named("service.tasks.writer") 192 | return &tasks{ 193 | db: db, 194 | logger: tasksLogger, 195 | meter: meter, 196 | } 197 | } 198 | 199 | // NewTasksReader initializes a new tasksv1.TasksWriterServiceServer implementation. 200 | func NewTasksReader(db *gorm.DB, logger *zap.Logger, meter metric.Meter) tasksv1.TasksReaderServiceServer { 201 | tasksLogger := logger.Named("service.tasks.reader") 202 | return &tasks{ 203 | db: db, 204 | logger: tasksLogger, 205 | meter: meter, 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /internal/service/tasks_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | tasksv1 "github.com/marcoshuck/todo/api/tasks/v1" 7 | "github.com/marcoshuck/todo/internal/domain" 8 | "github.com/stretchr/testify/suite" 9 | "go.opentelemetry.io/otel/metric/noop" 10 | "go.uber.org/zap" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | "google.golang.org/protobuf/types/known/fieldmaskpb" 14 | "gorm.io/driver/sqlite" 15 | "gorm.io/gorm" 16 | "testing" 17 | "time" 18 | ) 19 | 20 | func TestTasksServiceSuite(t *testing.T) { 21 | suite.Run(t, new(TasksServiceTestSuite)) 22 | } 23 | 24 | type TasksServiceTestSuite struct { 25 | suite.Suite 26 | db *gorm.DB 27 | writer tasksv1.TasksWriterServiceServer 28 | reader tasksv1.TasksReaderServiceServer 29 | logger *zap.Logger 30 | } 31 | 32 | func (suite *TasksServiceTestSuite) SetupSuite() { 33 | 34 | } 35 | 36 | func (suite *TasksServiceTestSuite) SetupTest() { 37 | var err error 38 | 39 | suite.logger, err = zap.NewDevelopment() 40 | suite.Require().NoError(err) 41 | 42 | suite.db, err = gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) 43 | suite.Require().NoError(err) 44 | suite.db = suite.db.Debug() 45 | suite.Require().NoError(suite.db.Migrator().AutoMigrate(&domain.Task{})) 46 | 47 | suite.writer = NewTasksWriter(suite.db, suite.logger, noop.NewMeterProvider().Meter("")) 48 | suite.reader = NewTasksReader(suite.db, suite.logger, noop.NewMeterProvider().Meter("")) 49 | } 50 | 51 | func (suite *TasksServiceTestSuite) TearDownTest() { 52 | db, err := suite.db.DB() 53 | suite.Require().NoError(err) 54 | suite.Assert().NoError(db.Close()) 55 | } 56 | 57 | func (suite *TasksServiceTestSuite) TearDownSuite() { 58 | 59 | } 60 | 61 | func (suite *TasksServiceTestSuite) TestCreate_Success() { 62 | var before int64 63 | suite.Require().NoError(suite.db.Model(&domain.Task{}).Count(&before).Error) 64 | 65 | const title = "test" 66 | res, err := suite.writer.CreateTask(context.Background(), &tasksv1.CreateTaskRequest{ 67 | Task: &tasksv1.Task{ 68 | Title: title, 69 | }, 70 | }) 71 | suite.Assert().NoError(err) 72 | suite.Assert().NotNil(res) 73 | suite.Assert().Equal(title, res.GetTitle()) 74 | suite.Assert().NotZero(res.GetCreateTime().AsTime()) 75 | suite.Assert().NotZero(res.GetUpdateTime().AsTime()) 76 | 77 | var after int64 78 | suite.Require().NoError(suite.db.Model(&domain.Task{}).Count(&after).Error) 79 | suite.NotEqual(before, after) 80 | suite.Equal(before+1, after) 81 | } 82 | 83 | func (suite *TasksServiceTestSuite) TestGet_Success() { 84 | ctx := context.Background() 85 | 86 | expected, err := suite.writer.CreateTask(ctx, &tasksv1.CreateTaskRequest{ 87 | Task: &tasksv1.Task{ 88 | Title: "A test", 89 | }, 90 | }) 91 | suite.Require().NoError(err) 92 | 93 | response, err := suite.reader.GetTask(ctx, &tasksv1.GetTaskRequest{Id: expected.GetId()}) 94 | suite.Assert().NoError(err) 95 | 96 | suite.Assert().Equal(expected.GetTitle(), response.GetTitle()) 97 | } 98 | 99 | func (suite *TasksServiceTestSuite) TestGet_NotFound() { 100 | ctx := context.Background() 101 | 102 | _, err := suite.reader.GetTask(ctx, &tasksv1.GetTaskRequest{Id: 199452}) 103 | suite.Assert().Error(err) 104 | suite.Assert().ErrorIs(err, status.Error(codes.NotFound, "task not found")) 105 | } 106 | 107 | func (suite *TasksServiceTestSuite) TestList_Empty() { 108 | ctx := context.Background() 109 | 110 | response, err := suite.reader.ListTasks(ctx, &tasksv1.ListTasksRequest{ 111 | PageSize: 0, 112 | PageToken: "", 113 | }) 114 | suite.Assert().NoError(err) 115 | suite.Assert().Empty(response.GetTasks()) 116 | } 117 | 118 | func (suite *TasksServiceTestSuite) TestList_Success() { 119 | ctx := context.Background() 120 | 121 | list := make([]domain.Task, 0, 10) 122 | for i := 1; i <= 10; i++ { 123 | list = append(list, domain.Task{ 124 | Model: gorm.Model{ 125 | CreatedAt: time.Now().Add(-time.Duration(i) * time.Hour), 126 | UpdatedAt: time.Now().Add(-time.Duration(i) * time.Hour), 127 | }, 128 | Title: fmt.Sprintf("%s %d", suite.T().Name(), i), 129 | }) 130 | } 131 | suite.Require().NoError(suite.db.Create(list).Error) 132 | 133 | var expected int64 134 | suite.Require().NoError(suite.db.Model(&domain.Task{}).Count(&expected).Error) 135 | 136 | response, err := suite.reader.ListTasks(ctx, &tasksv1.ListTasksRequest{ 137 | PageSize: 5, 138 | PageToken: "", 139 | }) 140 | suite.Assert().NoError(err) 141 | suite.Assert().NotEmpty(response.GetTasks()) 142 | suite.Assert().Len(response.GetTasks(), 5) 143 | suite.Assert().NotEmpty(response.GetNextPageToken()) 144 | 145 | response, err = suite.reader.ListTasks(ctx, &tasksv1.ListTasksRequest{ 146 | PageSize: 5, 147 | PageToken: response.GetNextPageToken(), 148 | }) 149 | suite.Assert().NoError(err) 150 | suite.Assert().NotEmpty(response.GetTasks()) 151 | suite.Assert().Len(response.GetTasks(), 5) 152 | suite.Assert().Empty(response.GetNextPageToken()) 153 | } 154 | 155 | func (suite *TasksServiceTestSuite) TestDelete_NotFound() { 156 | ctx := context.Background() 157 | 158 | _, err := suite.writer.DeleteTask(ctx, &tasksv1.DeleteTaskRequest{Id: 116644725}) 159 | suite.Assert().Error(err) 160 | suite.Assert().ErrorIs(err, status.Error(codes.NotFound, "task not found")) 161 | } 162 | 163 | func (suite *TasksServiceTestSuite) TestDelete_Success() { 164 | ctx := context.Background() 165 | 166 | expected, err := suite.writer.CreateTask(ctx, &tasksv1.CreateTaskRequest{ 167 | Task: &tasksv1.Task{ 168 | Title: "A test", 169 | }, 170 | }) 171 | suite.Require().NoError(err) 172 | 173 | response, err := suite.writer.DeleteTask(ctx, &tasksv1.DeleteTaskRequest{Id: expected.GetId()}) 174 | suite.Assert().NoError(err) 175 | suite.Assert().Equal(expected.GetTitle(), response.GetTitle()) 176 | } 177 | 178 | func (suite *TasksServiceTestSuite) TestUndelete_Success() { 179 | ctx := context.Background() 180 | 181 | expected, err := suite.writer.CreateTask(ctx, &tasksv1.CreateTaskRequest{ 182 | Task: &tasksv1.Task{ 183 | Title: "A test", 184 | }, 185 | }) 186 | suite.Require().NoError(err) 187 | 188 | res, err := suite.reader.ListTasks(ctx, &tasksv1.ListTasksRequest{}) 189 | suite.Require().NoError(err) 190 | before := len(res.GetTasks()) 191 | 192 | response, err := suite.writer.DeleteTask(ctx, &tasksv1.DeleteTaskRequest{Id: expected.GetId()}) 193 | suite.Require().NoError(err) 194 | 195 | res, err = suite.reader.ListTasks(ctx, &tasksv1.ListTasksRequest{}) 196 | suite.Require().NoError(err) 197 | after := len(res.GetTasks()) 198 | suite.Require().NotEqual(before, after) 199 | 200 | task, err := suite.writer.UndeleteTask(ctx, &tasksv1.UndeleteTaskRequest{Id: response.GetId()}) 201 | suite.Assert().NoError(err) 202 | suite.Assert().NotNil(task) 203 | 204 | res, err = suite.reader.ListTasks(ctx, &tasksv1.ListTasksRequest{}) 205 | suite.Require().NoError(err) 206 | suite.Require().Equal(before, len(res.GetTasks())) 207 | } 208 | 209 | func (suite *TasksServiceTestSuite) TestUpdate_Success() { 210 | ctx := context.Background() 211 | 212 | before, err := suite.writer.CreateTask(ctx, &tasksv1.CreateTaskRequest{ 213 | Task: &tasksv1.Task{ 214 | Title: "A test", 215 | }, 216 | }) 217 | suite.Require().NoError(err) 218 | 219 | after, err := suite.writer.UpdateTask(ctx, &tasksv1.UpdateTaskRequest{ 220 | Task: &tasksv1.Task{ 221 | Id: before.GetId(), 222 | Title: "An updated title", 223 | }, 224 | UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title"}}, 225 | }) 226 | suite.Assert().NoError(err) 227 | 228 | suite.Assert().NotEqual(before.GetTitle(), after.GetTitle()) 229 | suite.Assert().Equal(before.GetId(), after.GetId()) 230 | suite.Assert().Equal(before.GetDescription(), after.GetDescription()) 231 | 232 | final, err := suite.reader.GetTask(ctx, &tasksv1.GetTaskRequest{Id: before.GetId()}) 233 | suite.Require().NoError(err) 234 | 235 | suite.Assert().Equal(after, final) 236 | } 237 | -------------------------------------------------------------------------------- /internal/telemetry/common.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/gojaguar/jaguar/config" 7 | "go.opentelemetry.io/otel/sdk/resource" 8 | semconv "go.opentelemetry.io/otel/semconv/v1.21.0" 9 | "google.golang.org/grpc" 10 | "google.golang.org/grpc/credentials/insecure" 11 | ) 12 | 13 | func newResource(ctx context.Context, cfg config.Config) (*resource.Resource, error) { 14 | return resource.New(ctx, 15 | resource.WithAttributes( 16 | semconv.ServiceName(cfg.Name), 17 | semconv.DeploymentEnvironment(cfg.Environment), 18 | ), 19 | ) 20 | } 21 | 22 | func newClient(addr string) (*grpc.ClientConn, error) { 23 | conn, err := grpc.NewClient(addr, 24 | grpc.WithTransportCredentials(insecure.NewCredentials()), 25 | ) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to create gRPC connection to collector: %w", err) 28 | } 29 | return conn, nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/telemetry/logging.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "github.com/gojaguar/jaguar/config" 5 | "go.uber.org/zap" 6 | ) 7 | 8 | // SetupLogger initializes a new Zap Logger with the parameters specified by the given ServerConfig. 9 | func SetupLogger(cfg config.Config) (*zap.Logger, error) { 10 | var logger *zap.Logger 11 | var err error 12 | switch cfg.Environment { 13 | case "production": 14 | logger, err = zap.NewProduction() 15 | case "staging": 16 | logger, err = zap.NewDevelopment() 17 | default: 18 | logger = zap.NewNop() 19 | } 20 | if err != nil { 21 | return nil, err 22 | } 23 | logger = logger.Named(cfg.Name) 24 | return logger, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/telemetry/metrics.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/gojaguar/jaguar/config" 7 | "github.com/marcoshuck/todo/internal/conf" 8 | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" 9 | "go.opentelemetry.io/otel/metric" 10 | "go.opentelemetry.io/otel/metric/noop" 11 | metric_sdk "go.opentelemetry.io/otel/sdk/metric" 12 | ) 13 | 14 | func SetupMetrics(cfg config.Config, metrics conf.Metrics) (metric.MeterProvider, metric_sdk.Exporter, error) { 15 | if !metrics.Enabled { 16 | return noop.NewMeterProvider(), nil, nil 17 | } 18 | 19 | var meterProvider metric.MeterProvider 20 | var meterExporter metric_sdk.Exporter 21 | 22 | switch cfg.Environment { 23 | case "production", "staging": 24 | var err error 25 | meterProvider, meterExporter, err = newMetrics(cfg, metrics) 26 | if err != nil { 27 | return nil, nil, err 28 | } 29 | default: 30 | meterProvider = noop.NewMeterProvider() 31 | } 32 | 33 | return meterProvider, meterExporter, nil 34 | } 35 | 36 | func newMetrics(cfg config.Config, metrics conf.Metrics) (metric.MeterProvider, metric_sdk.Exporter, error) { 37 | ctx := context.Background() 38 | res, err := newResource(ctx, cfg) 39 | if err != nil { 40 | return nil, nil, err 41 | } 42 | conn, err := newClient(metrics.Address()) 43 | if err != nil { 44 | return nil, nil, err 45 | } 46 | meterExporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithGRPCConn(conn)) 47 | if err != nil { 48 | return nil, nil, fmt.Errorf("failed to create metrics exporter: %w", err) 49 | } 50 | meterProvider := metric_sdk.NewMeterProvider( 51 | metric_sdk.WithReader(metric_sdk.NewPeriodicReader(meterExporter)), 52 | metric_sdk.WithResource(res), 53 | ) 54 | return meterProvider, meterExporter, nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/telemetry/telemetry.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "github.com/gojaguar/jaguar/config" 5 | "github.com/marcoshuck/todo/internal/conf" 6 | "go.opentelemetry.io/otel/metric" 7 | "go.opentelemetry.io/otel/propagation" 8 | metric_sdk "go.opentelemetry.io/otel/sdk/metric" 9 | trace_sdk "go.opentelemetry.io/otel/sdk/trace" 10 | "go.opentelemetry.io/otel/trace" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | type Telemetry struct { 15 | Logger *zap.Logger 16 | TracerProvider trace.TracerProvider 17 | TraceExporter trace_sdk.SpanExporter 18 | MeterProvider metric.MeterProvider 19 | MeterExporter metric_sdk.Exporter 20 | Propagator propagation.TextMapPropagator 21 | } 22 | 23 | func SetupTelemetry(cfg config.Config, tracing conf.Tracing, metrics conf.Metrics) (Telemetry, error) { 24 | var t Telemetry 25 | var err error 26 | t.Logger, err = SetupLogger(cfg) 27 | if err != nil { 28 | return Telemetry{}, err 29 | } 30 | 31 | t.TracerProvider, t.TraceExporter, err = SetupTracing(cfg, tracing) 32 | if err != nil { 33 | return Telemetry{}, err 34 | } 35 | 36 | t.MeterProvider, t.MeterExporter, err = SetupMetrics(cfg, metrics) 37 | if err != nil { 38 | return Telemetry{}, err 39 | } 40 | 41 | t.Propagator = propagation.NewCompositeTextMapPropagator( 42 | propagation.TraceContext{}, 43 | propagation.Baggage{}, 44 | ) 45 | return t, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/telemetry/tracing.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/gojaguar/jaguar/config" 7 | "github.com/marcoshuck/todo/internal/conf" 8 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 9 | "go.opentelemetry.io/otel/propagation" 10 | trace_sdk "go.opentelemetry.io/otel/sdk/trace" 11 | "go.opentelemetry.io/otel/trace" 12 | "go.opentelemetry.io/otel/trace/noop" 13 | ) 14 | 15 | func SetupTracing(cfg config.Config, tracing conf.Tracing) (trace.TracerProvider, trace_sdk.SpanExporter, error) { 16 | if !tracing.Enabled { 17 | return noop.NewTracerProvider(), nil, nil 18 | } 19 | 20 | var tracerProvider trace.TracerProvider 21 | var traceExporter trace_sdk.SpanExporter 22 | switch cfg.Environment { 23 | case "production", "staging": 24 | var err error 25 | tracerProvider, traceExporter, err = newTracing(cfg, tracing) 26 | if err != nil { 27 | return nil, nil, err 28 | } 29 | default: 30 | tracerProvider = noop.NewTracerProvider() 31 | } 32 | 33 | return tracerProvider, traceExporter, nil 34 | } 35 | 36 | func newTracing(cfg config.Config, tracing conf.Tracing) (trace.TracerProvider, trace_sdk.SpanExporter, error) { 37 | ctx := context.Background() 38 | res, err := newResource(ctx, cfg) 39 | if err != nil { 40 | return nil, nil, err 41 | } 42 | conn, err := newClient(tracing.Address()) 43 | if err != nil { 44 | return nil, nil, err 45 | } 46 | traceExporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn)) 47 | if err != nil { 48 | return nil, nil, fmt.Errorf("failed to create trace exporter: %w", err) 49 | } 50 | propagation.NewCompositeTextMapPropagator() 51 | bsp := trace_sdk.NewBatchSpanProcessor(traceExporter) 52 | tracerProvider := trace_sdk.NewTracerProvider( 53 | trace_sdk.WithSampler(trace_sdk.AlwaysSample()), 54 | trace_sdk.WithResource(res), 55 | trace_sdk.WithSpanProcessor(bsp), 56 | ) 57 | return tracerProvider, traceExporter, nil 58 | } 59 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo", 3 | "version": "0.6.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "todo", 9 | "version": "0.6.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@bufbuild/protobuf": "^1.7.2", 13 | "@playwright/test": "^1.41.2", 14 | "@types/node": "^20.11.17" 15 | } 16 | }, 17 | "node_modules/@bufbuild/protobuf": { 18 | "version": "1.7.2", 19 | "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.7.2.tgz", 20 | "integrity": "sha512-i5GE2Dk5ekdlK1TR7SugY4LWRrKSfb5T1Qn4unpIMbfxoeGKERKQ59HG3iYewacGD10SR7UzevfPnh6my4tNmQ==", 21 | "dev": true 22 | }, 23 | "node_modules/@playwright/test": { 24 | "version": "1.41.2", 25 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", 26 | "integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==", 27 | "dev": true, 28 | "dependencies": { 29 | "playwright": "1.41.2" 30 | }, 31 | "bin": { 32 | "playwright": "cli.js" 33 | }, 34 | "engines": { 35 | "node": ">=16" 36 | } 37 | }, 38 | "node_modules/@types/node": { 39 | "version": "20.11.17", 40 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", 41 | "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", 42 | "dev": true, 43 | "dependencies": { 44 | "undici-types": "~5.26.4" 45 | } 46 | }, 47 | "node_modules/fsevents": { 48 | "version": "2.3.2", 49 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 50 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 51 | "dev": true, 52 | "hasInstallScript": true, 53 | "optional": true, 54 | "os": [ 55 | "darwin" 56 | ], 57 | "engines": { 58 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 59 | } 60 | }, 61 | "node_modules/playwright": { 62 | "version": "1.41.2", 63 | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz", 64 | "integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==", 65 | "dev": true, 66 | "dependencies": { 67 | "playwright-core": "1.41.2" 68 | }, 69 | "bin": { 70 | "playwright": "cli.js" 71 | }, 72 | "engines": { 73 | "node": ">=16" 74 | }, 75 | "optionalDependencies": { 76 | "fsevents": "2.3.2" 77 | } 78 | }, 79 | "node_modules/playwright-core": { 80 | "version": "1.41.2", 81 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz", 82 | "integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==", 83 | "dev": true, 84 | "bin": { 85 | "playwright-core": "cli.js" 86 | }, 87 | "engines": { 88 | "node": ">=16" 89 | } 90 | }, 91 | "node_modules/undici-types": { 92 | "version": "5.26.5", 93 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 94 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 95 | "dev": true 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo", 3 | "version": "0.6.0", 4 | "description": "A production-grade web service written in Go, showcasing various features and technologies.", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": {}, 10 | "keywords": [], 11 | "author": "Marcos Huck", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@bufbuild/protobuf": "^1.7.2", 15 | "@playwright/test": "^1.41.2", 16 | "@types/node": "^20.11.17" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, devices} from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | baseURL: 'http://localhost:8080/v1', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: 'on-first-retry', 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: 'chromium', 37 | use: {...devices['Desktop Chrome']}, 38 | }, 39 | // 40 | // { 41 | // name: 'firefox', 42 | // use: { ...devices['Desktop Firefox'] }, 43 | // }, 44 | // 45 | // { 46 | // name: 'webkit', 47 | // use: { ...devices['Desktop Safari'] }, 48 | // }, 49 | 50 | /* Test against mobile viewports. */ 51 | // { 52 | // name: 'Mobile Chrome', 53 | // use: { ...devices['Pixel 5'] }, 54 | // }, 55 | // { 56 | // name: 'Mobile Safari', 57 | // use: { ...devices['iPhone 12'] }, 58 | // }, 59 | 60 | /* Test against branded browsers. */ 61 | // { 62 | // name: 'Microsoft Edge', 63 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 64 | // }, 65 | // { 66 | // name: 'Google Chrome', 67 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 68 | // }, 69 | ], 70 | 71 | /* Run your local dev server before starting the tests */ 72 | // webServer: { 73 | // command: 'npm run start', 74 | // url: 'http://127.0.0.1:3000', 75 | // reuseExistingServer: !process.env.CI, 76 | // }, 77 | }); 78 | -------------------------------------------------------------------------------- /tests/tasks.spec.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@playwright/test'; 2 | import {createTask, deleteTask, getTask, listTasks, undeleteTask, updateTask} from "./tasks.utils"; 3 | import {Task} from "../api/tasks/v1/tasks_pb"; 4 | 5 | 6 | test('POST /v1/tasks', async ({request}) => { 7 | let input = { 8 | title: 'An awesome task', 9 | description: 'An awesome description for an awesome task', 10 | }; 11 | 12 | let output = await createTask(request, input); 13 | 14 | // Assertions 15 | expect(output.title).toEqual(input.title); 16 | expect(output.description).toEqual(input.description); 17 | expect(output.id).toBeGreaterThan(0); 18 | expect(output.createTime).not.toBeNull(); 19 | expect(output.updateTime).not.toBeNull(); 20 | }) 21 | 22 | test('GET /v1/tasks/:id', async ({request}) => { 23 | let input = { 24 | title: 'An awesome task', 25 | description: 'An awesome description for an awesome task', 26 | }; 27 | 28 | let expected = await createTask(request, input); 29 | 30 | 31 | // Get the data 32 | const {data: output, response} = await getTask(request, expected.id); 33 | expect(response.ok()).toBeTruthy(); 34 | 35 | // Assertions 36 | expect(output.title).toEqual(expected.title); 37 | expect(output.description).toEqual(expected.description); 38 | expect(output.id).toEqual(expected.id); 39 | }) 40 | 41 | test('GET /v1/tasks', async ({request}) => { 42 | let input = { 43 | title: 'An awesome task', 44 | description: 'An awesome description for an awesome task', 45 | }; 46 | 47 | const list: Task[] = []; 48 | 49 | for (let i = 0; i < 10; i++) { 50 | let task = await createTask(request, input); 51 | list.push(task); 52 | await new Promise(r => setTimeout(r, 1000)); 53 | } 54 | 55 | let response = await listTasks(request, 5, undefined); 56 | 57 | expect(response.data.nextPageToken).not.toBe(''); 58 | 59 | 60 | for (const task of list) { 61 | await deleteTask(request, task.id); 62 | } 63 | }) 64 | 65 | test('DELETE /v1/tasks/:id', async ({request}) => { 66 | let input = { 67 | title: 'An awesome task', 68 | description: 'An awesome description for an awesome task', 69 | }; 70 | 71 | let expected = await createTask(request, input); 72 | 73 | // Get the task 74 | let {data: output, response} = await getTask(request, expected.id); 75 | expect(response.ok()).toBeTruthy(); 76 | 77 | // Delete the task 78 | await deleteTask(request, output.id); 79 | 80 | response = await request.get(`/v1/tasks/${output.id}`) 81 | expect(response.ok()).toBeFalsy(); 82 | expect(response.status()).toEqual(404); 83 | }) 84 | 85 | test('POST /v1/tasks:undelete', async ({request}) => { 86 | let input = { 87 | title: 'An awesome task', 88 | description: 'An awesome description for an awesome task', 89 | }; 90 | 91 | let expected = await createTask(request, input); 92 | 93 | // Get the task 94 | let {data: output, response} = await getTask(request, expected.id); 95 | expect(response.ok()).toBeTruthy(); 96 | 97 | // Delete the task 98 | await deleteTask(request, output.id); 99 | 100 | // It does not exist, hence not found 101 | response = await request.get(`/v1/tasks/${output.id}`) 102 | expect(response.ok()).toBeFalsy(); 103 | expect(response.status()).toEqual(404); 104 | 105 | // But if we undelete 106 | await undeleteTask(request, output.id); 107 | 108 | // We rever the state back to found 109 | response = await request.get(`/v1/tasks/${output.id}`) 110 | expect(response.ok()).toBeTruthy(); 111 | }) 112 | 113 | test('PATCH /v1/tasks/:id', async ({request}) => { 114 | let input = { 115 | title: 'An awesome task', 116 | description: 'An awesome description for an awesome task', 117 | }; 118 | 119 | let expected = await createTask(request, input); 120 | 121 | // Get the task 122 | let {data: output, response} = await getTask(request, expected.id); 123 | expect(response.ok()).toBeTruthy(); 124 | 125 | // Partially update a task 126 | const patch = { 127 | id: Number(output.id), 128 | description: 'A modified description for an awesome task' 129 | } 130 | await updateTask(request, output.id, patch); 131 | 132 | ({data: output, response} = await getTask(request, expected.id)); 133 | expect(response.ok()).toBeTruthy(); 134 | expect(output.description).toEqual(patch.description); 135 | }) -------------------------------------------------------------------------------- /tests/tasks.utils.ts: -------------------------------------------------------------------------------- 1 | import {APIRequestContext, expect} from "@playwright/test"; 2 | import {ListTasksResponse, Task} from '../api/tasks/v1/tasks_pb'; 3 | 4 | export async function createTask(request: APIRequestContext, input: any): Promise { 5 | // Send the request and wait for the response. 6 | const response = await request.post('/v1/tasks', { 7 | data: input, 8 | }); 9 | 10 | // Status: OK 11 | expect(response.ok()).toBeTruthy(); 12 | 13 | // Read the body 14 | const body = await response.body(); 15 | return Task.fromJsonString(body.toString()); 16 | } 17 | 18 | export async function getTask(request: APIRequestContext, id: bigint) { 19 | const response = await request.get(`/v1/tasks/${id}`); 20 | const body = await response.body(); 21 | return { 22 | response: response, 23 | data: Task.fromJsonString(body.toString()), 24 | } 25 | } 26 | 27 | export async function listTasks(request: APIRequestContext, size: number, nextPageToken: string | undefined) { 28 | const response = await request.get(`/v1/tasks`, { 29 | params: { 30 | "page_size": size, 31 | "page_token": nextPageToken, 32 | }, 33 | }); 34 | const body = await response.body(); 35 | 36 | return { 37 | response: response, 38 | data: ListTasksResponse.fromJsonString(body.toString()), 39 | } 40 | } 41 | 42 | export async function deleteTask(request: APIRequestContext, id: bigint): Promise { 43 | const response = await request.delete(`/v1/tasks/${id}`); 44 | expect(response.ok()).toBeTruthy(); 45 | } 46 | 47 | export async function undeleteTask(request: APIRequestContext, id: bigint): Promise { 48 | const response = await request.post('/v1/tasks:undelete', { 49 | data: { 50 | id: Number(id), 51 | } 52 | }); 53 | expect(response.ok()).toBeTruthy(); 54 | } 55 | 56 | export async function updateTask(request: APIRequestContext, id: bigint, payload: any): Promise { 57 | const response = await request.patch(`/v1/tasks/${id}`, { 58 | data: payload, 59 | }); 60 | // Read the body 61 | const body = await response.body(); 62 | return Task.fromJsonString(body.toString()); 63 | } -------------------------------------------------------------------------------- /ui/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /ui/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": [ 4 | "angular.ng-template" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /ui/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /ui/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # App 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.1.3. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. 28 | -------------------------------------------------------------------------------- /ui/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "app": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "outputPath": "dist/app", 21 | "index": "src/index.html", 22 | "browser": "src/main.ts", 23 | "polyfills": [ 24 | "zone.js" 25 | ], 26 | "tsConfig": "tsconfig.app.json", 27 | "inlineStyleLanguage": "scss", 28 | "assets": [ 29 | { 30 | "glob": "**/*", 31 | "input": "public" 32 | } 33 | ], 34 | "styles": [ 35 | "@angular/material/prebuilt-themes/azure-blue.css", 36 | "src/styles.scss" 37 | ], 38 | "scripts": [], 39 | "server": "src/main.server.ts", 40 | "prerender": true, 41 | "ssr": { 42 | "entry": "server.ts" 43 | } 44 | }, 45 | "configurations": { 46 | "production": { 47 | "budgets": [ 48 | { 49 | "type": "initial", 50 | "maximumWarning": "500kB", 51 | "maximumError": "1MB" 52 | }, 53 | { 54 | "type": "anyComponentStyle", 55 | "maximumWarning": "2kB", 56 | "maximumError": "4kB" 57 | } 58 | ], 59 | "outputHashing": "all" 60 | }, 61 | "development": { 62 | "optimization": false, 63 | "extractLicenses": false, 64 | "sourceMap": true, 65 | "fileReplacements": [ 66 | { 67 | "replace": "src/environments/environment.ts", 68 | "with": "src/environments/environment.development.ts" 69 | } 70 | ] 71 | } 72 | }, 73 | "defaultConfiguration": "production" 74 | }, 75 | "serve": { 76 | "builder": "@angular-devkit/build-angular:dev-server", 77 | "configurations": { 78 | "production": { 79 | "buildTarget": "app:build:production" 80 | }, 81 | "development": { 82 | "buildTarget": "app:build:development" 83 | } 84 | }, 85 | "defaultConfiguration": "development" 86 | }, 87 | "extract-i18n": { 88 | "builder": "@angular-devkit/build-angular:extract-i18n" 89 | }, 90 | "test": { 91 | "builder": "@angular-devkit/build-angular:karma", 92 | "options": { 93 | "polyfills": [ 94 | "zone.js", 95 | "zone.js/testing" 96 | ], 97 | "tsConfig": "tsconfig.spec.json", 98 | "inlineStyleLanguage": "scss", 99 | "assets": [ 100 | { 101 | "glob": "**/*", 102 | "input": "public" 103 | } 104 | ], 105 | "styles": [ 106 | "@angular/material/prebuilt-themes/azure-blue.css", 107 | "src/styles.scss" 108 | ], 109 | "scripts": [] 110 | } 111 | }, 112 | "lint": { 113 | "builder": "@angular-eslint/builder:lint", 114 | "options": { 115 | "lintFilePatterns": [ 116 | "src/**/*.ts", 117 | "src/**/*.html" 118 | ] 119 | } 120 | } 121 | } 122 | } 123 | }, 124 | "cli": { 125 | "schematicCollections": [ 126 | "@angular-eslint/schematics" 127 | ] 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /ui/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const eslint = require("@eslint/js"); 3 | const tseslint = require("typescript-eslint"); 4 | const angular = require("angular-eslint"); 5 | 6 | module.exports = tseslint.config( 7 | { 8 | files: ["**/*.ts"], 9 | extends: [ 10 | eslint.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ...tseslint.configs.stylistic, 13 | ...angular.configs.tsRecommended, 14 | ], 15 | processor: angular.processInlineTemplates, 16 | rules: { 17 | "@angular-eslint/directive-selector": [ 18 | "error", 19 | { 20 | type: "attribute", 21 | prefix: "app", 22 | style: "camelCase", 23 | }, 24 | ], 25 | "@angular-eslint/component-selector": [ 26 | "error", 27 | { 28 | type: "element", 29 | prefix: "app", 30 | style: "kebab-case", 31 | }, 32 | ], 33 | }, 34 | }, 35 | { 36 | files: ["**/*.html"], 37 | extends: [ 38 | ...angular.configs.templateRecommended, 39 | ...angular.configs.templateAccessibility, 40 | ], 41 | rules: {}, 42 | } 43 | ); 44 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "serve:ssr:app": "node dist/app/server/server.mjs", 11 | "lint": "ng lint" 12 | }, 13 | "private": true, 14 | "dependencies": { 15 | "@angular/animations": "^18.1.0", 16 | "@angular/cdk": "^18.1.3", 17 | "@angular/common": "^18.1.0", 18 | "@angular/compiler": "^18.1.0", 19 | "@angular/core": "^18.1.0", 20 | "@angular/forms": "^18.1.0", 21 | "@angular/material": "^18.1.3", 22 | "@angular/platform-browser": "^18.1.0", 23 | "@angular/platform-browser-dynamic": "^18.1.0", 24 | "@angular/platform-server": "^18.1.0", 25 | "@angular/router": "^18.1.0", 26 | "@angular/ssr": "^18.1.3", 27 | "@bufbuild/buf": "^1.35.1", 28 | "@bufbuild/protobuf": "^2.0.0", 29 | "@bufbuild/protoc-gen-es": "^2.0.0", 30 | "express": "^4.21.1", 31 | "rxjs": "~7.8.0", 32 | "tslib": "^2.3.0", 33 | "zone.js": "~0.14.3" 34 | }, 35 | "devDependencies": { 36 | "@angular-devkit/build-angular": "^18.1.3", 37 | "@angular/cli": "^18.1.3", 38 | "@angular/compiler-cli": "^18.1.0", 39 | "@types/express": "^4.17.17", 40 | "@types/jasmine": "~5.1.0", 41 | "@types/node": "^18.18.0", 42 | "angular-eslint": "18.2.0", 43 | "eslint": "^9.8.0", 44 | "jasmine-core": "~5.1.0", 45 | "karma": "~6.4.0", 46 | "karma-chrome-launcher": "~3.2.0", 47 | "karma-coverage": "~2.2.0", 48 | "karma-jasmine": "~5.1.0", 49 | "karma-jasmine-html-reporter": "~2.1.0", 50 | "typescript": "~5.5.2", 51 | "typescript-eslint": "8.0.0" 52 | } 53 | } -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcoshuck/todo/d81be40c3e9c75174b3ff0f4b5a0d0385a5ed6ff/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/server.ts: -------------------------------------------------------------------------------- 1 | import {APP_BASE_HREF} from '@angular/common'; 2 | import {CommonEngine} from '@angular/ssr'; 3 | import express from 'express'; 4 | import {fileURLToPath} from 'node:url'; 5 | import {dirname, join, resolve} from 'node:path'; 6 | import bootstrap from './src/main.server'; 7 | 8 | // The Express app is exported so that it can be used by serverless Functions. 9 | export function app(): express.Express { 10 | const server = express(); 11 | const serverDistFolder = dirname(fileURLToPath(import.meta.url)); 12 | const browserDistFolder = resolve(serverDistFolder, '../browser'); 13 | const indexHtml = join(serverDistFolder, 'index.server.html'); 14 | 15 | const commonEngine = new CommonEngine(); 16 | 17 | server.set('view engine', 'html'); 18 | server.set('views', browserDistFolder); 19 | 20 | // Example Express Rest API endpoints 21 | // server.get('/api/**', (req, res) => { }); 22 | // Serve static files from /browser 23 | server.get('**', express.static(browserDistFolder, { 24 | maxAge: '1y', 25 | index: 'index.html', 26 | })); 27 | 28 | // All regular routes use the Angular engine 29 | server.get('**', (req, res, next) => { 30 | const {protocol, originalUrl, baseUrl, headers} = req; 31 | 32 | commonEngine 33 | .render({ 34 | bootstrap, 35 | documentFilePath: indexHtml, 36 | url: `${protocol}://${headers.host}${originalUrl}`, 37 | publicPath: browserDistFolder, 38 | providers: [{provide: APP_BASE_HREF, useValue: baseUrl}], 39 | }) 40 | .then((html) => res.send(html)) 41 | .catch((err) => next(err)); 42 | }); 43 | 44 | return server; 45 | } 46 | 47 | function run(): void { 48 | const port = process.env['PORT'] || 4000; 49 | 50 | // Start up the Node server 51 | const server = app(); 52 | server.listen(port, () => { 53 | console.log(`Node Express server listening on http://localhost:${port}`); 54 | }); 55 | } 56 | 57 | run(); 58 | -------------------------------------------------------------------------------- /ui/src/api/tasks/v1/tasks_pb.d.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es v1.10.0 2 | // @generated from file api/tasks/v1/tasks.proto (package api.tasks.v1, syntax proto3) 3 | /* eslint-disable */ 4 | // @ts-nocheck 5 | 6 | import type { BinaryReadOptions, FieldList, FieldMask, JsonReadOptions, JsonValue, PartialMessage, PlainMessage, Timestamp } from "@bufbuild/protobuf"; 7 | import { Message, proto3 } from "@bufbuild/protobuf"; 8 | 9 | /** 10 | * @generated from message api.tasks.v1.Task 11 | */ 12 | export declare class Task extends Message { 13 | /** 14 | * @generated from field: int64 id = 1; 15 | */ 16 | id: bigint; 17 | 18 | /** 19 | * @generated from field: string title = 2; 20 | */ 21 | title: string; 22 | 23 | /** 24 | * @generated from field: string description = 3; 25 | */ 26 | description: string; 27 | 28 | /** 29 | * @generated from field: google.protobuf.Timestamp deadline = 4; 30 | */ 31 | deadline?: Timestamp; 32 | 33 | /** 34 | * @generated from field: google.protobuf.Timestamp completed_at = 5; 35 | */ 36 | completedAt?: Timestamp; 37 | 38 | /** 39 | * @generated from field: google.protobuf.Timestamp create_time = 1000; 40 | */ 41 | createTime?: Timestamp; 42 | 43 | /** 44 | * @generated from field: google.protobuf.Timestamp update_time = 1001; 45 | */ 46 | updateTime?: Timestamp; 47 | 48 | constructor(data?: PartialMessage); 49 | 50 | static readonly runtime: typeof proto3; 51 | static readonly typeName = "api.tasks.v1.Task"; 52 | static readonly fields: FieldList; 53 | 54 | static fromBinary(bytes: Uint8Array, options?: Partial): Task; 55 | 56 | static fromJson(jsonValue: JsonValue, options?: Partial): Task; 57 | 58 | static fromJsonString(jsonString: string, options?: Partial): Task; 59 | 60 | static equals(a: Task | PlainMessage | undefined, b: Task | PlainMessage | undefined): boolean; 61 | } 62 | 63 | /** 64 | * @generated from message api.tasks.v1.CreateTaskRequest 65 | */ 66 | export declare class CreateTaskRequest extends Message { 67 | /** 68 | * Task is the the task to create. 69 | * 70 | * @generated from field: api.tasks.v1.Task task = 2; 71 | */ 72 | task?: Task; 73 | 74 | constructor(data?: PartialMessage); 75 | 76 | static readonly runtime: typeof proto3; 77 | static readonly typeName = "api.tasks.v1.CreateTaskRequest"; 78 | static readonly fields: FieldList; 79 | 80 | static fromBinary(bytes: Uint8Array, options?: Partial): CreateTaskRequest; 81 | 82 | static fromJson(jsonValue: JsonValue, options?: Partial): CreateTaskRequest; 83 | 84 | static fromJsonString(jsonString: string, options?: Partial): CreateTaskRequest; 85 | 86 | static equals(a: CreateTaskRequest | PlainMessage | undefined, b: CreateTaskRequest | PlainMessage | undefined): boolean; 87 | } 88 | 89 | /** 90 | * @generated from message api.tasks.v1.DeleteTaskRequest 91 | */ 92 | export declare class DeleteTaskRequest extends Message { 93 | /** 94 | * @generated from field: int64 id = 1; 95 | */ 96 | id: bigint; 97 | 98 | constructor(data?: PartialMessage); 99 | 100 | static readonly runtime: typeof proto3; 101 | static readonly typeName = "api.tasks.v1.DeleteTaskRequest"; 102 | static readonly fields: FieldList; 103 | 104 | static fromBinary(bytes: Uint8Array, options?: Partial): DeleteTaskRequest; 105 | 106 | static fromJson(jsonValue: JsonValue, options?: Partial): DeleteTaskRequest; 107 | 108 | static fromJsonString(jsonString: string, options?: Partial): DeleteTaskRequest; 109 | 110 | static equals(a: DeleteTaskRequest | PlainMessage | undefined, b: DeleteTaskRequest | PlainMessage | undefined): boolean; 111 | } 112 | 113 | /** 114 | * @generated from message api.tasks.v1.UndeleteTaskRequest 115 | */ 116 | export declare class UndeleteTaskRequest extends Message { 117 | /** 118 | * @generated from field: int64 id = 1; 119 | */ 120 | id: bigint; 121 | 122 | constructor(data?: PartialMessage); 123 | 124 | static readonly runtime: typeof proto3; 125 | static readonly typeName = "api.tasks.v1.UndeleteTaskRequest"; 126 | static readonly fields: FieldList; 127 | 128 | static fromBinary(bytes: Uint8Array, options?: Partial): UndeleteTaskRequest; 129 | 130 | static fromJson(jsonValue: JsonValue, options?: Partial): UndeleteTaskRequest; 131 | 132 | static fromJsonString(jsonString: string, options?: Partial): UndeleteTaskRequest; 133 | 134 | static equals(a: UndeleteTaskRequest | PlainMessage | undefined, b: UndeleteTaskRequest | PlainMessage | undefined): boolean; 135 | } 136 | 137 | /** 138 | * @generated from message api.tasks.v1.UpdateTaskRequest 139 | */ 140 | export declare class UpdateTaskRequest extends Message { 141 | /** 142 | * @generated from field: api.tasks.v1.Task task = 1; 143 | */ 144 | task?: Task; 145 | 146 | /** 147 | * @generated from field: google.protobuf.FieldMask update_mask = 2; 148 | */ 149 | updateMask?: FieldMask; 150 | 151 | constructor(data?: PartialMessage); 152 | 153 | static readonly runtime: typeof proto3; 154 | static readonly typeName = "api.tasks.v1.UpdateTaskRequest"; 155 | static readonly fields: FieldList; 156 | 157 | static fromBinary(bytes: Uint8Array, options?: Partial): UpdateTaskRequest; 158 | 159 | static fromJson(jsonValue: JsonValue, options?: Partial): UpdateTaskRequest; 160 | 161 | static fromJsonString(jsonString: string, options?: Partial): UpdateTaskRequest; 162 | 163 | static equals(a: UpdateTaskRequest | PlainMessage | undefined, b: UpdateTaskRequest | PlainMessage | undefined): boolean; 164 | } 165 | 166 | /** 167 | * @generated from message api.tasks.v1.GetTaskRequest 168 | */ 169 | export declare class GetTaskRequest extends Message { 170 | /** 171 | * @generated from field: int64 id = 1; 172 | */ 173 | id: bigint; 174 | 175 | constructor(data?: PartialMessage); 176 | 177 | static readonly runtime: typeof proto3; 178 | static readonly typeName = "api.tasks.v1.GetTaskRequest"; 179 | static readonly fields: FieldList; 180 | 181 | static fromBinary(bytes: Uint8Array, options?: Partial): GetTaskRequest; 182 | 183 | static fromJson(jsonValue: JsonValue, options?: Partial): GetTaskRequest; 184 | 185 | static fromJsonString(jsonString: string, options?: Partial): GetTaskRequest; 186 | 187 | static equals(a: GetTaskRequest | PlainMessage | undefined, b: GetTaskRequest | PlainMessage | undefined): boolean; 188 | } 189 | 190 | /** 191 | * @generated from message api.tasks.v1.ListTasksRequest 192 | */ 193 | export declare class ListTasksRequest extends Message { 194 | /** 195 | * @generated from field: int32 page_size = 2; 196 | */ 197 | pageSize: number; 198 | 199 | /** 200 | * @generated from field: string page_token = 3; 201 | */ 202 | pageToken: string; 203 | 204 | constructor(data?: PartialMessage); 205 | 206 | static readonly runtime: typeof proto3; 207 | static readonly typeName = "api.tasks.v1.ListTasksRequest"; 208 | static readonly fields: FieldList; 209 | 210 | static fromBinary(bytes: Uint8Array, options?: Partial): ListTasksRequest; 211 | 212 | static fromJson(jsonValue: JsonValue, options?: Partial): ListTasksRequest; 213 | 214 | static fromJsonString(jsonString: string, options?: Partial): ListTasksRequest; 215 | 216 | static equals(a: ListTasksRequest | PlainMessage | undefined, b: ListTasksRequest | PlainMessage | undefined): boolean; 217 | } 218 | 219 | /** 220 | * @generated from message api.tasks.v1.ListTasksResponse 221 | */ 222 | export declare class ListTasksResponse extends Message { 223 | /** 224 | * @generated from field: repeated api.tasks.v1.Task tasks = 1; 225 | */ 226 | tasks: Task[]; 227 | 228 | /** 229 | * @generated from field: string next_page_token = 2; 230 | */ 231 | nextPageToken: string; 232 | 233 | constructor(data?: PartialMessage); 234 | 235 | static readonly runtime: typeof proto3; 236 | static readonly typeName = "api.tasks.v1.ListTasksResponse"; 237 | static readonly fields: FieldList; 238 | 239 | static fromBinary(bytes: Uint8Array, options?: Partial): ListTasksResponse; 240 | 241 | static fromJson(jsonValue: JsonValue, options?: Partial): ListTasksResponse; 242 | 243 | static fromJsonString(jsonString: string, options?: Partial): ListTasksResponse; 244 | 245 | static equals(a: ListTasksResponse | PlainMessage | undefined, b: ListTasksResponse | PlainMessage | undefined): boolean; 246 | } 247 | 248 | -------------------------------------------------------------------------------- /ui/src/api/tasks/v1/tasks_pb.js: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es v1.10.0 2 | // @generated from file api/tasks/v1/tasks.proto (package api.tasks.v1, syntax proto3) 3 | /* eslint-disable */ 4 | // @ts-nocheck 5 | 6 | import { FieldMask, proto3, Timestamp } from "@bufbuild/protobuf"; 7 | 8 | /** 9 | * @generated from message api.tasks.v1.Task 10 | */ 11 | export const Task = /*@__PURE__*/ proto3.makeMessageType( 12 | "api.tasks.v1.Task", 13 | () => [ 14 | { no: 1, name: "id", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, 15 | { no: 2, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 16 | { no: 3, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 17 | { no: 4, name: "deadline", kind: "message", T: Timestamp }, 18 | { no: 5, name: "completed_at", kind: "message", T: Timestamp }, 19 | { no: 1000, name: "create_time", kind: "message", T: Timestamp }, 20 | { no: 1001, name: "update_time", kind: "message", T: Timestamp }, 21 | ], 22 | ); 23 | 24 | /** 25 | * @generated from message api.tasks.v1.CreateTaskRequest 26 | */ 27 | export const CreateTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 28 | "api.tasks.v1.CreateTaskRequest", 29 | () => [ 30 | { no: 2, name: "task", kind: "message", T: Task }, 31 | ], 32 | ); 33 | 34 | /** 35 | * @generated from message api.tasks.v1.DeleteTaskRequest 36 | */ 37 | export const DeleteTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 38 | "api.tasks.v1.DeleteTaskRequest", 39 | () => [ 40 | { no: 1, name: "id", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, 41 | ], 42 | ); 43 | 44 | /** 45 | * @generated from message api.tasks.v1.UndeleteTaskRequest 46 | */ 47 | export const UndeleteTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 48 | "api.tasks.v1.UndeleteTaskRequest", 49 | () => [ 50 | { no: 1, name: "id", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, 51 | ], 52 | ); 53 | 54 | /** 55 | * @generated from message api.tasks.v1.UpdateTaskRequest 56 | */ 57 | export const UpdateTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 58 | "api.tasks.v1.UpdateTaskRequest", 59 | () => [ 60 | { no: 1, name: "task", kind: "message", T: Task }, 61 | { no: 2, name: "update_mask", kind: "message", T: FieldMask }, 62 | ], 63 | ); 64 | 65 | /** 66 | * @generated from message api.tasks.v1.GetTaskRequest 67 | */ 68 | export const GetTaskRequest = /*@__PURE__*/ proto3.makeMessageType( 69 | "api.tasks.v1.GetTaskRequest", 70 | () => [ 71 | { no: 1, name: "id", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, 72 | ], 73 | ); 74 | 75 | /** 76 | * @generated from message api.tasks.v1.ListTasksRequest 77 | */ 78 | export const ListTasksRequest = /*@__PURE__*/ proto3.makeMessageType( 79 | "api.tasks.v1.ListTasksRequest", 80 | () => [ 81 | { no: 2, name: "page_size", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, 82 | { no: 3, name: "page_token", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 83 | ], 84 | ); 85 | 86 | /** 87 | * @generated from message api.tasks.v1.ListTasksResponse 88 | */ 89 | export const ListTasksResponse = /*@__PURE__*/ proto3.makeMessageType( 90 | "api.tasks.v1.ListTasksResponse", 91 | () => [ 92 | { no: 1, name: "tasks", kind: "message", T: Task, repeated: true }, 93 | { no: 2, name: "next_page_token", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 94 | ], 95 | ); 96 | 97 | -------------------------------------------------------------------------------- /ui/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /ui/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcoshuck/todo/d81be40c3e9c75174b3ff0f4b5a0d0385a5ed6ff/ui/src/app/app.component.scss -------------------------------------------------------------------------------- /ui/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | import {AppComponent} from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(async () => { 6 | await TestBed.configureTestingModule({ 7 | imports: [AppComponent], 8 | }).compileComponents(); 9 | }); 10 | 11 | it('should create the app', () => { 12 | const fixture = TestBed.createComponent(AppComponent); 13 | const app = fixture.componentInstance; 14 | expect(app).toBeTruthy(); 15 | }); 16 | 17 | it(`should have the 'app' title`, () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.componentInstance; 20 | expect(app.title).toEqual('app'); 21 | }); 22 | 23 | it('should render title', () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | fixture.detectChanges(); 26 | const compiled = fixture.nativeElement as HTMLElement; 27 | expect(compiled.querySelector('h1')?.textContent).toContain('Hello, app'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /ui/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {RouterOutlet} from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | standalone: true, 7 | imports: [RouterOutlet], 8 | templateUrl: './app.component.html', 9 | styleUrl: './app.component.scss', 10 | }) 11 | export class AppComponent { 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/app/app.config.server.ts: -------------------------------------------------------------------------------- 1 | import {ApplicationConfig, mergeApplicationConfig} from '@angular/core'; 2 | import {provideServerRendering} from '@angular/platform-server'; 3 | import {appConfig} from './app.config'; 4 | 5 | const serverConfig: ApplicationConfig = { 6 | providers: [ 7 | provideServerRendering() 8 | ] 9 | }; 10 | 11 | export const config = mergeApplicationConfig(appConfig, serverConfig); 12 | -------------------------------------------------------------------------------- /ui/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core'; 2 | import {provideRouter} from '@angular/router'; 3 | 4 | import {routes} from './app.routes'; 5 | import {provideClientHydration} from '@angular/platform-browser'; 6 | import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; 7 | 8 | export const appConfig: ApplicationConfig = { 9 | providers: [ 10 | provideZoneChangeDetection({eventCoalescing: true}), 11 | provideRouter(routes), 12 | provideClientHydration(), 13 | provideAnimationsAsync(), 14 | ] 15 | }; 16 | -------------------------------------------------------------------------------- /ui/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import {Routes} from '@angular/router'; 2 | import {HomeViewComponent} from "./views/home-view/home-view.component"; 3 | import {TaskViewComponent} from "./views/task-view/task-view.component"; 4 | 5 | export const routes: Routes = [ 6 | { 7 | title: 'Home', 8 | path: '', 9 | component: HomeViewComponent, 10 | }, 11 | { 12 | title: 'Task', 13 | path: 'tasks/:id', 14 | component: TaskViewComponent, 15 | } 16 | ]; 17 | -------------------------------------------------------------------------------- /ui/src/app/components/icon-button-star/icon-button-star.component.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /ui/src/app/components/icon-button-star/icon-button-star.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcoshuck/todo/d81be40c3e9c75174b3ff0f4b5a0d0385a5ed6ff/ui/src/app/components/icon-button-star/icon-button-star.component.scss -------------------------------------------------------------------------------- /ui/src/app/components/icon-button-star/icon-button-star.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {IconButtonStarComponent} from './icon-button-star.component'; 4 | 5 | describe('IconButtonStarComponent', () => { 6 | let component: IconButtonStarComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [IconButtonStarComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(IconButtonStarComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /ui/src/app/components/icon-button-star/icon-button-star.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, effect, model} from '@angular/core'; 2 | import {MatIcon} from "@angular/material/icon"; 3 | import {MatIconButton} from "@angular/material/button"; 4 | import {environment} from "../../../environments/environment"; 5 | 6 | @Component({ 7 | selector: 'app-icon-button-star', 8 | standalone: true, 9 | imports: [ 10 | MatIcon, 11 | MatIconButton 12 | ], 13 | templateUrl: './icon-button-star.component.html', 14 | styleUrl: './icon-button-star.component.scss' 15 | }) 16 | export class IconButtonStarComponent { 17 | public starred = model(false); 18 | protected icon = 'star_border'; 19 | 20 | constructor() { 21 | effect(() => { 22 | this.icon = this.starred() ? 'star' : 'star_border'; 23 | }); 24 | } 25 | 26 | star() { 27 | this.starred.set(!this.starred()) 28 | } 29 | 30 | protected readonly environment = environment; 31 | } 32 | -------------------------------------------------------------------------------- /ui/src/app/layouts/main-layout/main-layout.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /ui/src/app/layouts/main-layout/main-layout.component.scss: -------------------------------------------------------------------------------- 1 | main { 2 | margin: 2vh 5vw; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/app/layouts/main-layout/main-layout.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {MainLayoutComponent} from './main-layout.component'; 4 | 5 | describe('MainLayoutComponent', () => { 6 | let component: MainLayoutComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [MainLayoutComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(MainLayoutComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /ui/src/app/layouts/main-layout/main-layout.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-main-layout', 5 | standalone: true, 6 | imports: [], 7 | templateUrl: './main-layout.component.html', 8 | styleUrl: './main-layout.component.scss' 9 | }) 10 | export class MainLayoutComponent { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-details/task-details.component.html: -------------------------------------------------------------------------------- 1 | @if (task().id <= 0) { 2 | 3 | } @else { 4 | 5 | {{ task().title }} 6 | {{ task().description }} 7 | {{ task().deadline }} 8 | 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-details/task-details.component.scss: -------------------------------------------------------------------------------- 1 | .spacer { 2 | flex: 1 1 auto; 3 | } 4 | 5 | .title { 6 | height: 2rem; 7 | } 8 | 9 | .description { 10 | height: 1.8rem; 11 | } 12 | 13 | .spinner { 14 | margin: 0 auto; 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-details/task-details.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {TaskDetailsComponent} from './task-details.component'; 4 | 5 | describe('TaskDetailsComponent', () => { 6 | let component: TaskDetailsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TaskDetailsComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TaskDetailsComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-details/task-details.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, input} from '@angular/core'; 2 | import {Task} from '../../../api/tasks/v1/tasks_pb'; 3 | import {MatList, MatListItem} from "@angular/material/list"; 4 | import {provideNativeDateAdapter} from "@angular/material/core"; 5 | import {MatProgressSpinner} from "@angular/material/progress-spinner"; 6 | 7 | @Component({ 8 | selector: 'app-task-details', 9 | standalone: true, 10 | imports: [ 11 | MatProgressSpinner, 12 | MatList, 13 | MatListItem 14 | ], 15 | templateUrl: './task-details.component.html', 16 | styleUrl: './task-details.component.scss', 17 | changeDetection: ChangeDetectionStrategy.OnPush, 18 | providers: [provideNativeDateAdapter()], 19 | }) 20 | export class TaskDetailsComponent { 21 | public task = input.required(); 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-list-item/task-list-item.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | {{ task.title }} 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-list-item/task-list-item.component.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | cursor: pointer; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-list-item/task-list-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {TaskListItemComponent} from './task-list-item.component'; 4 | 5 | describe('TaskListItemComponent', () => { 6 | let component: TaskListItemComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TaskListItemComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TaskListItemComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-list-item/task-list-item.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, OnInit, signal} from '@angular/core'; 2 | import {MatCheckbox} from "@angular/material/checkbox"; 3 | import {MatGridList, MatGridTile} from "@angular/material/grid-list"; 4 | import {Task} from "../../../api/tasks/v1/tasks_pb"; 5 | import {Router} from "@angular/router"; 6 | import {IconButtonStarComponent} from "../../components/icon-button-star/icon-button-star.component"; 7 | import {environment} from "../../../environments/environment"; 8 | 9 | @Component({ 10 | selector: 'app-task-list-item', 11 | standalone: true, 12 | imports: [ 13 | MatGridList, 14 | MatGridTile, 15 | MatCheckbox, 16 | IconButtonStarComponent 17 | ], 18 | templateUrl: './task-list-item.component.html', 19 | styleUrl: './task-list-item.component.scss' 20 | }) 21 | export class TaskListItemComponent implements OnInit { 22 | @Input({required: true}) 23 | public task!: Task; 24 | 25 | protected completed = signal(false); 26 | protected starred = signal(false); 27 | 28 | constructor(private router: Router) { 29 | } 30 | 31 | ngOnInit(): void { 32 | this.completed.set(!!this.task.completedAt); 33 | } 34 | 35 | star() { 36 | this.starred.set(!this.starred()); 37 | } 38 | 39 | async seeMore() { 40 | await this.router.navigateByUrl(`/tasks/${this.task.id}`); 41 | } 42 | 43 | protected readonly environment = environment; 44 | } 45 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-list/task-list.component.html: -------------------------------------------------------------------------------- 1 | @for (task of tasks(); track task.id) { 2 | 5 | } @empty { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-list/task-list.component.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | margin: 0 auto; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-list/task-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {TaskListComponent} from './task-list.component'; 4 | 5 | describe('TaskListComponent', () => { 6 | let component: TaskListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TaskListComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TaskListComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task-list/task-list.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, input} from '@angular/core'; 2 | import {TaskListItemComponent} from "../task-list-item/task-list-item.component"; 3 | import {Task} from '../../../api/tasks/v1/tasks_pb'; 4 | import {MatProgressSpinner} from "@angular/material/progress-spinner"; 5 | 6 | @Component({ 7 | selector: 'app-task-list', 8 | standalone: true, 9 | imports: [ 10 | TaskListItemComponent, 11 | MatProgressSpinner 12 | ], 13 | templateUrl: './task-list.component.html', 14 | styleUrl: './task-list.component.scss' 15 | }) 16 | export class TaskListComponent { 17 | public tasks = input.required(); 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {TaskService} from './task.service'; 4 | 5 | describe('TaskService', () => { 6 | let service: TaskService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(TaskService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /ui/src/app/tasks/task.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {CreateTaskRequest, ListTasksResponse, Task, UpdateTaskRequest} from "../../api/tasks/v1/tasks_pb"; 3 | import {FetchBackend, HttpClient} from "@angular/common/http"; 4 | import {Observable} from "rxjs"; 5 | import {environment} from "../../environments/environment"; 6 | 7 | declare interface ITaskService { 8 | create(task: Task): Observable; 9 | 10 | get(id: bigint): Observable; 11 | 12 | list(size: number, cursor?: string): Observable; 13 | 14 | update(id: bigint, task: Task): Observable; 15 | 16 | delete(id: bigint): Observable; 17 | 18 | undelete(id: bigint): Observable; 19 | } 20 | 21 | @Injectable({ 22 | providedIn: 'root' 23 | }) 24 | export class TaskService implements ITaskService { 25 | private http: HttpClient; 26 | private readonly baseURL: string; 27 | 28 | constructor() { 29 | this.baseURL = `${environment.apiUrl}/v1/tasks`; 30 | this.http = new HttpClient(new FetchBackend()) 31 | } 32 | 33 | create(task: Task): Observable { 34 | const body: CreateTaskRequest = { 35 | task: task, 36 | } 37 | return this.http.post(this.baseURL, body); 38 | } 39 | 40 | get(id: bigint): Observable { 41 | return this.http.get(`${this.baseURL}/${id}`); 42 | } 43 | 44 | list(size: number, cursor?: string): Observable { 45 | const params = { 46 | 'page_size': size || 50, 47 | 'page_token': cursor || '', 48 | } 49 | return this.http.get(this.baseURL, { 50 | params: params, 51 | }); 52 | } 53 | 54 | update(id: bigint, task: Task): Observable { 55 | const body: UpdateTaskRequest = { 56 | task: task, 57 | updateMask: task, 58 | } 59 | return this.http.patch(`${this.baseURL}/${id}`, body); 60 | } 61 | 62 | delete(id: bigint): Observable { 63 | return this.http.delete(`${this.baseURL}/${id}`); 64 | } 65 | 66 | undelete(id: bigint): Observable { 67 | return this.http.post(`${this.baseURL}/${id}:undelete`, null); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /ui/src/app/views/home-view/home-view.component.html: -------------------------------------------------------------------------------- 1 | 2 |

Todo App

3 | 4 | 5 | 6 | Tasks 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /ui/src/app/views/home-view/home-view.component.scss: -------------------------------------------------------------------------------- 1 | .paginator { 2 | margin: 0 auto; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/app/views/home-view/home-view.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {HomeViewComponent} from './home-view.component'; 4 | 5 | describe('HomeViewComponent', () => { 6 | let component: HomeViewComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [HomeViewComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(HomeViewComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /ui/src/app/views/home-view/home-view.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, effect, model, signal, WritableSignal} from '@angular/core'; 2 | import {MainLayoutComponent} from "../../layouts/main-layout/main-layout.component"; 3 | import { 4 | MatCard, 5 | MatCardActions, 6 | MatCardContent, 7 | MatCardFooter, 8 | MatCardHeader, 9 | MatCardTitle 10 | } from "@angular/material/card"; 11 | import {TaskService} from "../../tasks/task.service"; 12 | import {ListTasksResponse, Task} from "../../../api/tasks/v1/tasks_pb"; 13 | import {MatPaginator, PageEvent} from "@angular/material/paginator"; 14 | import {TaskListComponent} from "../../tasks/task-list/task-list.component"; 15 | import {environment} from "../../../environments/environment"; 16 | 17 | @Component({ 18 | selector: 'app-home-view', 19 | standalone: true, 20 | imports: [ 21 | MainLayoutComponent, 22 | MatCard, 23 | MatCardHeader, 24 | MatCardContent, 25 | TaskListComponent, 26 | MatCardFooter, 27 | MatCardActions, 28 | MatCardTitle, 29 | MatPaginator 30 | ], 31 | templateUrl: './home-view.component.html', 32 | styleUrl: './home-view.component.scss' 33 | }) 34 | export class HomeViewComponent { 35 | protected pageSizes: number[] = [10, 20, 30, 50, 100]; 36 | protected pageSize = model(10); 37 | protected tasks: WritableSignal = signal([]); 38 | private nextPageToken?: string; 39 | 40 | constructor(private readonly taskService: TaskService) { 41 | this.listTasks(); 42 | effect(() => { 43 | this.nextPageToken = undefined; 44 | this.listTasks(); 45 | }); 46 | } 47 | 48 | handlePageEvent(e: PageEvent) { 49 | this.pageSize.set(e.pageSize); 50 | } 51 | 52 | private listTasks() { 53 | this.taskService.list(this.pageSize(), this.nextPageToken).subscribe((response: ListTasksResponse) => { 54 | this.tasks.set(response.tasks); 55 | this.nextPageToken = response.nextPageToken; 56 | }) 57 | } 58 | 59 | protected readonly environment = environment; 60 | } 61 | -------------------------------------------------------------------------------- /ui/src/app/views/task-view/task-view.component.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | @if (task().id > 0) { 9 | {{ task().id }} 10 | } 11 | 12 | 13 | 14 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /ui/src/app/views/task-view/task-view.component.scss: -------------------------------------------------------------------------------- 1 | .spacer { 2 | flex: 1 1 auto; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/app/views/task-view/task-view.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {TaskViewComponent} from './task-view.component'; 4 | 5 | describe('TaskViewComponent', () => { 6 | let component: TaskViewComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [TaskViewComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(TaskViewComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /ui/src/app/views/task-view/task-view.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, signal} from '@angular/core'; 2 | import {MainLayoutComponent} from "../../layouts/main-layout/main-layout.component"; 3 | import {TaskService} from "../../tasks/task.service"; 4 | import {ActivatedRoute} from "@angular/router"; 5 | import {Location} from "@angular/common"; 6 | import {Task} from "../../../api/tasks/v1/tasks_pb"; 7 | import {TaskDetailsComponent} from "../../tasks/task-details/task-details.component"; 8 | import {IconButtonStarComponent} from "../../components/icon-button-star/icon-button-star.component"; 9 | import {MatIcon} from "@angular/material/icon"; 10 | import {MatIconButton} from "@angular/material/button"; 11 | import {MatToolbar} from "@angular/material/toolbar"; 12 | 13 | @Component({ 14 | selector: 'app-task-view', 15 | standalone: true, 16 | imports: [ 17 | MainLayoutComponent, 18 | TaskDetailsComponent, 19 | IconButtonStarComponent, 20 | MatIcon, 21 | MatIconButton, 22 | MatToolbar 23 | ], 24 | templateUrl: './task-view.component.html', 25 | styleUrl: './task-view.component.scss' 26 | }) 27 | export class TaskViewComponent { 28 | protected task = signal({ 29 | id: 0n, 30 | title: "", 31 | description: "", 32 | }) 33 | 34 | constructor( 35 | private taskService: TaskService, 36 | private activatedRoute: ActivatedRoute, 37 | private location: Location, 38 | ) { 39 | const id = this.activatedRoute.snapshot.paramMap.get('id'); 40 | if (!id) { 41 | console.error('Failed to process id') 42 | this.goBack(); 43 | } 44 | this.taskService.get(BigInt(id!)).subscribe((task => { 45 | this.task.set(task); 46 | })); 47 | } 48 | 49 | catch(err: unknown) { 50 | console.error(err); 51 | this.goBack(); 52 | } 53 | 54 | protected goBack() { 55 | this.location.back(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /ui/src/environments/environment.development.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | apiUrl: 'http://localhost:8080', 4 | features: { 5 | tasks: { 6 | star: false, 7 | pagination: false, 8 | creation: false, 9 | completed: false, 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /ui/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | apiUrl: 'https://todo.huck.com.ar/api', 4 | features: { 5 | tasks: { 6 | star: false, 7 | pagination: false, 8 | creation: false, 9 | completed: false, 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /ui/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Todo Application 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /ui/src/main.server.ts: -------------------------------------------------------------------------------- 1 | import {bootstrapApplication} from '@angular/platform-browser'; 2 | import {AppComponent} from './app/app.component'; 3 | import {config} from './app/app.config.server'; 4 | 5 | const bootstrap = () => bootstrapApplication(AppComponent, config); 6 | 7 | export default bootstrap; 8 | -------------------------------------------------------------------------------- /ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import {bootstrapApplication} from '@angular/platform-browser'; 2 | import {appConfig} from './app/app.config'; 3 | import {AppComponent} from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /ui/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, body { height: 100%; } 4 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 5 | -------------------------------------------------------------------------------- /ui/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./app", 7 | "types": [ 8 | "node" 9 | ] 10 | }, 11 | "files": [ 12 | "src/main.ts", 13 | "src/main.server.ts", 14 | "server.ts" 15 | ], 16 | "include": [ 17 | "src/**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "bundler", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "lib": [ 22 | "ES2022", 23 | "dom" 24 | ] 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ui/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": [ 8 | "jasmine" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.spec.ts", 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------