autoworkflow 3.1.5 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/analyze.md +19 -0
- package/.claude/commands/audit.md +26 -0
- package/.claude/commands/build.md +39 -0
- package/.claude/commands/commit.md +25 -0
- package/.claude/commands/fix.md +23 -0
- package/.claude/commands/plan.md +18 -0
- package/.claude/commands/suggest.md +23 -0
- package/.claude/commands/verify.md +18 -0
- package/.claude/hooks/post-bash-router.sh +20 -0
- package/.claude/hooks/post-commit.sh +140 -0
- package/.claude/hooks/pre-edit.sh +129 -0
- package/.claude/hooks/session-check.sh +79 -0
- package/.claude/settings.json +40 -6
- package/.claude/settings.local.json +3 -1
- package/.claude/skills/actix.md +337 -0
- package/.claude/skills/alembic.md +504 -0
- package/.claude/skills/angular.md +237 -0
- package/.claude/skills/api-design.md +187 -0
- package/.claude/skills/aspnet-core.md +377 -0
- package/.claude/skills/astro.md +245 -0
- package/.claude/skills/auth-clerk.md +327 -0
- package/.claude/skills/auth-firebase.md +367 -0
- package/.claude/skills/auth-nextauth.md +359 -0
- package/.claude/skills/auth-supabase.md +368 -0
- package/.claude/skills/axum.md +386 -0
- package/.claude/skills/blazor.md +456 -0
- package/.claude/skills/chi.md +348 -0
- package/.claude/skills/code-review.md +133 -0
- package/.claude/skills/csharp.md +296 -0
- package/.claude/skills/css-modules.md +325 -0
- package/.claude/skills/cypress.md +343 -0
- package/.claude/skills/debugging.md +133 -0
- package/.claude/skills/diesel.md +392 -0
- package/.claude/skills/django.md +301 -0
- package/.claude/skills/docker.md +319 -0
- package/.claude/skills/doctrine.md +473 -0
- package/.claude/skills/documentation.md +182 -0
- package/.claude/skills/dotnet.md +409 -0
- package/.claude/skills/drizzle.md +293 -0
- package/.claude/skills/echo.md +321 -0
- package/.claude/skills/eloquent.md +256 -0
- package/.claude/skills/emotion.md +426 -0
- package/.claude/skills/entity-framework.md +370 -0
- package/.claude/skills/express.md +316 -0
- package/.claude/skills/fastapi.md +329 -0
- package/.claude/skills/fastify.md +299 -0
- package/.claude/skills/fiber.md +315 -0
- package/.claude/skills/flask.md +322 -0
- package/.claude/skills/gin.md +342 -0
- package/.claude/skills/git.md +116 -0
- package/.claude/skills/github-actions.md +353 -0
- package/.claude/skills/go.md +377 -0
- package/.claude/skills/gorm.md +409 -0
- package/.claude/skills/graphql.md +478 -0
- package/.claude/skills/hibernate.md +379 -0
- package/.claude/skills/hono.md +306 -0
- package/.claude/skills/java.md +400 -0
- package/.claude/skills/jest.md +313 -0
- package/.claude/skills/jpa.md +282 -0
- package/.claude/skills/kotlin.md +347 -0
- package/.claude/skills/kubernetes.md +363 -0
- package/.claude/skills/laravel.md +414 -0
- package/.claude/skills/mcp-browser.md +320 -0
- package/.claude/skills/mcp-database.md +219 -0
- package/.claude/skills/mcp-fetch.md +241 -0
- package/.claude/skills/mcp-filesystem.md +204 -0
- package/.claude/skills/mcp-github.md +217 -0
- package/.claude/skills/mcp-memory.md +240 -0
- package/.claude/skills/mcp-search.md +218 -0
- package/.claude/skills/mcp-slack.md +262 -0
- package/.claude/skills/micronaut.md +388 -0
- package/.claude/skills/mongodb.md +319 -0
- package/.claude/skills/mongoose.md +355 -0
- package/.claude/skills/mysql.md +281 -0
- package/.claude/skills/nestjs.md +335 -0
- package/.claude/skills/nextjs-app-router.md +260 -0
- package/.claude/skills/nextjs-pages.md +172 -0
- package/.claude/skills/nuxt.md +202 -0
- package/.claude/skills/openapi.md +489 -0
- package/.claude/skills/performance.md +199 -0
- package/.claude/skills/php.md +398 -0
- package/.claude/skills/playwright.md +371 -0
- package/.claude/skills/postgresql.md +257 -0
- package/.claude/skills/prisma.md +293 -0
- package/.claude/skills/pydantic.md +304 -0
- package/.claude/skills/pytest.md +313 -0
- package/.claude/skills/python.md +272 -0
- package/.claude/skills/quarkus.md +377 -0
- package/.claude/skills/react.md +230 -0
- package/.claude/skills/redis.md +391 -0
- package/.claude/skills/refactoring.md +143 -0
- package/.claude/skills/remix.md +246 -0
- package/.claude/skills/rest-api.md +490 -0
- package/.claude/skills/rocket.md +366 -0
- package/.claude/skills/rust.md +341 -0
- package/.claude/skills/sass.md +380 -0
- package/.claude/skills/sea-orm.md +382 -0
- package/.claude/skills/security.md +167 -0
- package/.claude/skills/sequelize.md +395 -0
- package/.claude/skills/spring-boot.md +416 -0
- package/.claude/skills/sqlalchemy.md +269 -0
- package/.claude/skills/sqlx-rust.md +408 -0
- package/.claude/skills/state-jotai.md +346 -0
- package/.claude/skills/state-mobx.md +353 -0
- package/.claude/skills/state-pinia.md +431 -0
- package/.claude/skills/state-redux.md +337 -0
- package/.claude/skills/state-tanstack-query.md +434 -0
- package/.claude/skills/state-zustand.md +340 -0
- package/.claude/skills/styled-components.md +403 -0
- package/.claude/skills/svelte.md +238 -0
- package/.claude/skills/sveltekit.md +207 -0
- package/.claude/skills/symfony.md +437 -0
- package/.claude/skills/tailwind.md +279 -0
- package/.claude/skills/terraform.md +394 -0
- package/.claude/skills/testing-library.md +371 -0
- package/.claude/skills/trpc.md +426 -0
- package/.claude/skills/typeorm.md +368 -0
- package/.claude/skills/vitest.md +330 -0
- package/.claude/skills/vue.md +202 -0
- package/.claude/skills/warp.md +365 -0
- package/README.md +135 -52
- package/package.json +1 -1
- package/system/triggers.md +152 -11
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Vue 3 Skill (Composition API)
|
|
2
|
+
|
|
3
|
+
## Component Structure
|
|
4
|
+
\`\`\`vue
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
import { ref, computed, watch, onMounted } from 'vue';
|
|
7
|
+
|
|
8
|
+
// Props with defaults
|
|
9
|
+
const props = withDefaults(defineProps<{
|
|
10
|
+
userId: string;
|
|
11
|
+
initialCount?: number;
|
|
12
|
+
}>(), {
|
|
13
|
+
initialCount: 0
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Emits with validation
|
|
17
|
+
const emit = defineEmits<{
|
|
18
|
+
(e: 'update', value: number): void;
|
|
19
|
+
(e: 'submit', data: FormData): void;
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
// Reactive State
|
|
23
|
+
const count = ref(props.initialCount);
|
|
24
|
+
const user = ref<User | null>(null);
|
|
25
|
+
|
|
26
|
+
// Computed
|
|
27
|
+
const doubleCount = computed(() => count.value * 2);
|
|
28
|
+
|
|
29
|
+
// Methods
|
|
30
|
+
function increment() {
|
|
31
|
+
count.value++;
|
|
32
|
+
emit('update', count.value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Lifecycle
|
|
36
|
+
onMounted(async () => {
|
|
37
|
+
user.value = await fetchUser(props.userId);
|
|
38
|
+
});
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div>
|
|
43
|
+
<h2>{{ user?.name }}</h2>
|
|
44
|
+
<p>Count: {{ count }} (Double: {{ doubleCount }})</p>
|
|
45
|
+
<button @click="increment">Increment</button>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
48
|
+
\`\`\`
|
|
49
|
+
|
|
50
|
+
## Composables (Custom Hooks)
|
|
51
|
+
\`\`\`typescript
|
|
52
|
+
// composables/useUser.ts
|
|
53
|
+
import { ref, onMounted } from 'vue';
|
|
54
|
+
|
|
55
|
+
export function useUser(userId: Ref<string>) {
|
|
56
|
+
const user = ref<User | null>(null);
|
|
57
|
+
const loading = ref(true);
|
|
58
|
+
const error = ref<Error | null>(null);
|
|
59
|
+
|
|
60
|
+
async function fetchUser() {
|
|
61
|
+
loading.value = true;
|
|
62
|
+
try {
|
|
63
|
+
user.value = await api.getUser(userId.value);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
error.value = e as Error;
|
|
66
|
+
} finally {
|
|
67
|
+
loading.value = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Refetch when userId changes
|
|
72
|
+
watch(userId, fetchUser, { immediate: true });
|
|
73
|
+
|
|
74
|
+
return { user, loading, error, refetch: fetchUser };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Usage in component
|
|
78
|
+
const { user, loading, error } = useUser(toRef(props, 'userId'));
|
|
79
|
+
\`\`\`
|
|
80
|
+
|
|
81
|
+
## Watch & WatchEffect
|
|
82
|
+
\`\`\`typescript
|
|
83
|
+
// Watch specific ref
|
|
84
|
+
watch(count, (newVal, oldVal) => {
|
|
85
|
+
console.log(\`Count changed from \${oldVal} to \${newVal}\`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Watch multiple sources
|
|
89
|
+
watch([firstName, lastName], ([first, last]) => {
|
|
90
|
+
fullName.value = \`\${first} \${last}\`;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Deep watch for objects
|
|
94
|
+
watch(user, (newUser) => {
|
|
95
|
+
saveToLocalStorage(newUser);
|
|
96
|
+
}, { deep: true });
|
|
97
|
+
|
|
98
|
+
// WatchEffect - auto-tracks dependencies
|
|
99
|
+
watchEffect(() => {
|
|
100
|
+
// Automatically re-runs when user.value or count.value changes
|
|
101
|
+
console.log(\`User: \${user.value?.name}, Count: \${count.value}\`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Cleanup
|
|
105
|
+
watchEffect((onCleanup) => {
|
|
106
|
+
const timer = setInterval(() => tick(), 1000);
|
|
107
|
+
onCleanup(() => clearInterval(timer));
|
|
108
|
+
});
|
|
109
|
+
\`\`\`
|
|
110
|
+
|
|
111
|
+
## Provide/Inject (Dependency Injection)
|
|
112
|
+
\`\`\`typescript
|
|
113
|
+
// Parent component (provide)
|
|
114
|
+
import { provide, ref } from 'vue';
|
|
115
|
+
|
|
116
|
+
const theme = ref('dark');
|
|
117
|
+
const toggleTheme = () => {
|
|
118
|
+
theme.value = theme.value === 'dark' ? 'light' : 'dark';
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
provide('theme', { theme, toggleTheme });
|
|
122
|
+
|
|
123
|
+
// Child component (inject)
|
|
124
|
+
import { inject } from 'vue';
|
|
125
|
+
|
|
126
|
+
const { theme, toggleTheme } = inject('theme')!;
|
|
127
|
+
|
|
128
|
+
// With TypeScript - define injection key
|
|
129
|
+
// types/injection-keys.ts
|
|
130
|
+
import type { InjectionKey, Ref } from 'vue';
|
|
131
|
+
|
|
132
|
+
export const ThemeKey: InjectionKey<{
|
|
133
|
+
theme: Ref<string>;
|
|
134
|
+
toggleTheme: () => void;
|
|
135
|
+
}> = Symbol('theme');
|
|
136
|
+
\`\`\`
|
|
137
|
+
|
|
138
|
+
## v-model Customization
|
|
139
|
+
\`\`\`vue
|
|
140
|
+
<script setup lang="ts">
|
|
141
|
+
// Single v-model
|
|
142
|
+
const modelValue = defineModel<string>();
|
|
143
|
+
|
|
144
|
+
// Named v-model with modifier
|
|
145
|
+
const [firstName, firstNameModifiers] = defineModel<string>('firstName');
|
|
146
|
+
const [lastName] = defineModel<string>('lastName');
|
|
147
|
+
|
|
148
|
+
// Usage: <MyInput v-model:firstName="first" v-model:lastName="last" />
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<template>
|
|
152
|
+
<input :value="modelValue" @input="modelValue = $event.target.value" />
|
|
153
|
+
</template>
|
|
154
|
+
\`\`\`
|
|
155
|
+
|
|
156
|
+
## Slots
|
|
157
|
+
\`\`\`vue
|
|
158
|
+
<!-- Parent -->
|
|
159
|
+
<Card>
|
|
160
|
+
<template #header>
|
|
161
|
+
<h1>Title</h1>
|
|
162
|
+
</template>
|
|
163
|
+
|
|
164
|
+
<template #default="{ item }">
|
|
165
|
+
<p>{{ item.name }}</p>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
168
|
+
<template #footer>
|
|
169
|
+
<button>Submit</button>
|
|
170
|
+
</template>
|
|
171
|
+
</Card>
|
|
172
|
+
|
|
173
|
+
<!-- Card.vue -->
|
|
174
|
+
<template>
|
|
175
|
+
<div class="card">
|
|
176
|
+
<header>
|
|
177
|
+
<slot name="header" />
|
|
178
|
+
</header>
|
|
179
|
+
<main>
|
|
180
|
+
<slot :item="currentItem" />
|
|
181
|
+
</main>
|
|
182
|
+
<footer>
|
|
183
|
+
<slot name="footer" />
|
|
184
|
+
</footer>
|
|
185
|
+
</div>
|
|
186
|
+
</template>
|
|
187
|
+
\`\`\`
|
|
188
|
+
|
|
189
|
+
## ❌ DON'T
|
|
190
|
+
- Mutate props directly
|
|
191
|
+
- Forget to use .value with refs in script
|
|
192
|
+
- Use reactive() for primitives (use ref)
|
|
193
|
+
- Create watchers without cleanup when needed
|
|
194
|
+
- Overuse provide/inject (prefer props for direct parent-child)
|
|
195
|
+
|
|
196
|
+
## ✅ DO
|
|
197
|
+
- Use \`<script setup>\` for cleaner code
|
|
198
|
+
- Use TypeScript for props/emits
|
|
199
|
+
- Create composables for reusable logic
|
|
200
|
+
- Use computed for derived state
|
|
201
|
+
- Use watchEffect for side effects with auto-tracking
|
|
202
|
+
- Provide injection keys with TypeScript
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# Warp Framework Skill
|
|
2
|
+
|
|
3
|
+
## Application Setup
|
|
4
|
+
\`\`\`rust
|
|
5
|
+
use warp::Filter;
|
|
6
|
+
use std::net::SocketAddr;
|
|
7
|
+
|
|
8
|
+
#[tokio::main]
|
|
9
|
+
async fn main() {
|
|
10
|
+
// Initialize tracing
|
|
11
|
+
tracing_subscriber::fmt::init();
|
|
12
|
+
|
|
13
|
+
// Create shared state
|
|
14
|
+
let db_pool = create_pool().await.expect("Failed to create pool");
|
|
15
|
+
let state = AppState { db: db_pool };
|
|
16
|
+
|
|
17
|
+
// Build routes
|
|
18
|
+
let routes = api_routes(state)
|
|
19
|
+
.with(warp::trace::request());
|
|
20
|
+
|
|
21
|
+
let addr: SocketAddr = "127.0.0.1:3000".parse().unwrap();
|
|
22
|
+
tracing::info!("Listening on {}", addr);
|
|
23
|
+
|
|
24
|
+
warp::serve(routes).run(addr).await;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn api_routes(state: AppState) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
|
28
|
+
let api = warp::path("api").and(warp::path("v1"));
|
|
29
|
+
|
|
30
|
+
api.and(
|
|
31
|
+
auth_routes(state.clone())
|
|
32
|
+
.or(user_routes(state.clone()))
|
|
33
|
+
.or(post_routes(state))
|
|
34
|
+
)
|
|
35
|
+
.recover(handle_rejection)
|
|
36
|
+
}
|
|
37
|
+
\`\`\`
|
|
38
|
+
|
|
39
|
+
## Route Definitions with Filters
|
|
40
|
+
\`\`\`rust
|
|
41
|
+
use warp::{Filter, Reply, Rejection};
|
|
42
|
+
|
|
43
|
+
fn user_routes(state: AppState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
|
|
44
|
+
let users = warp::path("users");
|
|
45
|
+
|
|
46
|
+
let list = users
|
|
47
|
+
.and(warp::path::end())
|
|
48
|
+
.and(warp::get())
|
|
49
|
+
.and(with_state(state.clone()))
|
|
50
|
+
.and(warp::query::<PaginationQuery>())
|
|
51
|
+
.and_then(list_users);
|
|
52
|
+
|
|
53
|
+
let create = users
|
|
54
|
+
.and(warp::path::end())
|
|
55
|
+
.and(warp::post())
|
|
56
|
+
.and(with_state(state.clone()))
|
|
57
|
+
.and(warp::body::json())
|
|
58
|
+
.and_then(create_user);
|
|
59
|
+
|
|
60
|
+
let get = users
|
|
61
|
+
.and(warp::path::param::<String>())
|
|
62
|
+
.and(warp::path::end())
|
|
63
|
+
.and(warp::get())
|
|
64
|
+
.and(with_state(state.clone()))
|
|
65
|
+
.and_then(get_user);
|
|
66
|
+
|
|
67
|
+
let update = users
|
|
68
|
+
.and(warp::path::param::<String>())
|
|
69
|
+
.and(warp::path::end())
|
|
70
|
+
.and(warp::put())
|
|
71
|
+
.and(with_state(state.clone()))
|
|
72
|
+
.and(warp::body::json())
|
|
73
|
+
.and_then(update_user);
|
|
74
|
+
|
|
75
|
+
let delete = users
|
|
76
|
+
.and(warp::path::param::<String>())
|
|
77
|
+
.and(warp::path::end())
|
|
78
|
+
.and(warp::delete())
|
|
79
|
+
.and(with_state(state))
|
|
80
|
+
.and_then(delete_user);
|
|
81
|
+
|
|
82
|
+
list.or(create).or(get).or(update).or(delete)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Protected routes with auth
|
|
86
|
+
fn protected_routes(state: AppState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
|
|
87
|
+
warp::path("protected")
|
|
88
|
+
.and(with_auth())
|
|
89
|
+
.and(with_state(state))
|
|
90
|
+
.and_then(protected_handler)
|
|
91
|
+
}
|
|
92
|
+
\`\`\`
|
|
93
|
+
|
|
94
|
+
## Custom Filters
|
|
95
|
+
\`\`\`rust
|
|
96
|
+
use std::sync::Arc;
|
|
97
|
+
|
|
98
|
+
#[derive(Clone)]
|
|
99
|
+
pub struct AppState {
|
|
100
|
+
pub db: PgPool,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// State filter
|
|
104
|
+
fn with_state(state: AppState) -> impl Filter<Extract = (AppState,), Error = std::convert::Infallible> + Clone {
|
|
105
|
+
warp::any().map(move || state.clone())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Auth filter
|
|
109
|
+
fn with_auth() -> impl Filter<Extract = (AuthUser,), Error = Rejection> + Clone {
|
|
110
|
+
warp::header::optional::<String>("Authorization")
|
|
111
|
+
.and_then(|auth_header: Option<String>| async move {
|
|
112
|
+
let token = auth_header
|
|
113
|
+
.as_ref()
|
|
114
|
+
.and_then(|h| h.strip_prefix("Bearer "))
|
|
115
|
+
.ok_or_else(|| warp::reject::custom(AppError::Unauthorized))?;
|
|
116
|
+
|
|
117
|
+
let claims = validate_token(token)
|
|
118
|
+
.map_err(|_| warp::reject::custom(AppError::Unauthorized))?;
|
|
119
|
+
|
|
120
|
+
Ok::<_, Rejection>(AuthUser {
|
|
121
|
+
user_id: claims.sub,
|
|
122
|
+
role: claims.role,
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Optional auth filter
|
|
128
|
+
fn with_optional_auth() -> impl Filter<Extract = (Option<AuthUser>,), Error = Rejection> + Clone {
|
|
129
|
+
warp::header::optional::<String>("Authorization")
|
|
130
|
+
.map(|auth_header: Option<String>| {
|
|
131
|
+
auth_header
|
|
132
|
+
.as_ref()
|
|
133
|
+
.and_then(|h| h.strip_prefix("Bearer "))
|
|
134
|
+
.and_then(|token| validate_token(token).ok())
|
|
135
|
+
.map(|claims| AuthUser {
|
|
136
|
+
user_id: claims.sub,
|
|
137
|
+
role: claims.role,
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Request body size limit
|
|
143
|
+
fn json_body<T: DeserializeOwned + Send>() -> impl Filter<Extract = (T,), Error = Rejection> + Clone {
|
|
144
|
+
warp::body::content_length_limit(1024 * 16) // 16KB
|
|
145
|
+
.and(warp::body::json())
|
|
146
|
+
}
|
|
147
|
+
\`\`\`
|
|
148
|
+
|
|
149
|
+
## Handlers
|
|
150
|
+
\`\`\`rust
|
|
151
|
+
use serde::{Deserialize, Serialize};
|
|
152
|
+
|
|
153
|
+
#[derive(Deserialize)]
|
|
154
|
+
pub struct CreateUserRequest {
|
|
155
|
+
pub email: String,
|
|
156
|
+
pub name: String,
|
|
157
|
+
pub password: String,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[derive(Deserialize, Default)]
|
|
161
|
+
pub struct PaginationQuery {
|
|
162
|
+
#[serde(default = "default_page")]
|
|
163
|
+
pub page: u32,
|
|
164
|
+
#[serde(default = "default_per_page")]
|
|
165
|
+
pub per_page: u32,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn default_page() -> u32 { 1 }
|
|
169
|
+
fn default_per_page() -> u32 { 20 }
|
|
170
|
+
|
|
171
|
+
async fn list_users(
|
|
172
|
+
state: AppState,
|
|
173
|
+
query: PaginationQuery,
|
|
174
|
+
) -> Result<impl Reply, Rejection> {
|
|
175
|
+
let offset = (query.page - 1) * query.per_page;
|
|
176
|
+
|
|
177
|
+
let users = sqlx::query_as!(User,
|
|
178
|
+
"SELECT * FROM users LIMIT $1 OFFSET $2",
|
|
179
|
+
query.per_page as i64,
|
|
180
|
+
offset as i64
|
|
181
|
+
)
|
|
182
|
+
.fetch_all(&state.db)
|
|
183
|
+
.await
|
|
184
|
+
.map_err(|e| warp::reject::custom(AppError::Database(e)))?;
|
|
185
|
+
|
|
186
|
+
Ok(warp::reply::json(&users))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async fn get_user(id: String, state: AppState) -> Result<impl Reply, Rejection> {
|
|
190
|
+
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
|
|
191
|
+
.fetch_optional(&state.db)
|
|
192
|
+
.await
|
|
193
|
+
.map_err(|e| warp::reject::custom(AppError::Database(e)))?
|
|
194
|
+
.ok_or_else(|| warp::reject::custom(AppError::NotFound))?;
|
|
195
|
+
|
|
196
|
+
Ok(warp::reply::json(&user))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async fn create_user(
|
|
200
|
+
state: AppState,
|
|
201
|
+
body: CreateUserRequest,
|
|
202
|
+
) -> Result<impl Reply, Rejection> {
|
|
203
|
+
// Validate
|
|
204
|
+
if body.email.is_empty() {
|
|
205
|
+
return Err(warp::reject::custom(AppError::Validation("Email required".into())));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let user = User::new(&body.email, &body.name, &body.password)
|
|
209
|
+
.map_err(|e| warp::reject::custom(e))?;
|
|
210
|
+
|
|
211
|
+
// Save user...
|
|
212
|
+
|
|
213
|
+
Ok(warp::reply::with_status(
|
|
214
|
+
warp::reply::json(&user),
|
|
215
|
+
warp::http::StatusCode::CREATED,
|
|
216
|
+
))
|
|
217
|
+
}
|
|
218
|
+
\`\`\`
|
|
219
|
+
|
|
220
|
+
## Error Handling with Rejections
|
|
221
|
+
\`\`\`rust
|
|
222
|
+
use warp::{reject::Reject, Rejection, Reply};
|
|
223
|
+
use thiserror::Error;
|
|
224
|
+
|
|
225
|
+
#[derive(Error, Debug)]
|
|
226
|
+
pub enum AppError {
|
|
227
|
+
#[error("Not found")]
|
|
228
|
+
NotFound,
|
|
229
|
+
|
|
230
|
+
#[error("Unauthorized")]
|
|
231
|
+
Unauthorized,
|
|
232
|
+
|
|
233
|
+
#[error("Validation error: {0}")]
|
|
234
|
+
Validation(String),
|
|
235
|
+
|
|
236
|
+
#[error("Database error")]
|
|
237
|
+
Database(#[from] sqlx::Error),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
impl Reject for AppError {}
|
|
241
|
+
|
|
242
|
+
#[derive(Serialize)]
|
|
243
|
+
struct ErrorResponse {
|
|
244
|
+
error: String,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async fn handle_rejection(err: Rejection) -> Result<impl Reply, std::convert::Infallible> {
|
|
248
|
+
let (code, message) = if err.is_not_found() {
|
|
249
|
+
(warp::http::StatusCode::NOT_FOUND, "Not found".to_string())
|
|
250
|
+
} else if let Some(e) = err.find::<AppError>() {
|
|
251
|
+
match e {
|
|
252
|
+
AppError::NotFound => (warp::http::StatusCode::NOT_FOUND, e.to_string()),
|
|
253
|
+
AppError::Unauthorized => (warp::http::StatusCode::UNAUTHORIZED, e.to_string()),
|
|
254
|
+
AppError::Validation(_) => (warp::http::StatusCode::BAD_REQUEST, e.to_string()),
|
|
255
|
+
AppError::Database(_) => {
|
|
256
|
+
tracing::error!("Database error: {:?}", e);
|
|
257
|
+
(warp::http::StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string())
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} else if err.find::<warp::reject::MethodNotAllowed>().is_some() {
|
|
261
|
+
(warp::http::StatusCode::METHOD_NOT_ALLOWED, "Method not allowed".to_string())
|
|
262
|
+
} else if err.find::<warp::reject::InvalidQuery>().is_some() {
|
|
263
|
+
(warp::http::StatusCode::BAD_REQUEST, "Invalid query".to_string())
|
|
264
|
+
} else if err.find::<warp::reject::PayloadTooLarge>().is_some() {
|
|
265
|
+
(warp::http::StatusCode::PAYLOAD_TOO_LARGE, "Payload too large".to_string())
|
|
266
|
+
} else {
|
|
267
|
+
tracing::error!("Unhandled rejection: {:?}", err);
|
|
268
|
+
(warp::http::StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string())
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
let json = warp::reply::json(&ErrorResponse { error: message });
|
|
272
|
+
Ok(warp::reply::with_status(json, code))
|
|
273
|
+
}
|
|
274
|
+
\`\`\`
|
|
275
|
+
|
|
276
|
+
## CORS
|
|
277
|
+
\`\`\`rust
|
|
278
|
+
use warp::cors;
|
|
279
|
+
|
|
280
|
+
fn with_cors() -> warp::cors::Builder {
|
|
281
|
+
warp::cors()
|
|
282
|
+
.allow_any_origin()
|
|
283
|
+
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
284
|
+
.allow_headers(vec!["Authorization", "Content-Type"])
|
|
285
|
+
.max_age(3600)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Apply to routes
|
|
289
|
+
let routes = api_routes(state)
|
|
290
|
+
.with(with_cors())
|
|
291
|
+
.with(warp::trace::request());
|
|
292
|
+
\`\`\`
|
|
293
|
+
|
|
294
|
+
## Testing
|
|
295
|
+
\`\`\`rust
|
|
296
|
+
#[cfg(test)]
|
|
297
|
+
mod tests {
|
|
298
|
+
use super::*;
|
|
299
|
+
use warp::http::StatusCode;
|
|
300
|
+
use warp::test::request;
|
|
301
|
+
|
|
302
|
+
async fn create_test_state() -> AppState {
|
|
303
|
+
AppState {
|
|
304
|
+
db: create_test_pool().await,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[tokio::test]
|
|
309
|
+
async fn test_get_user() {
|
|
310
|
+
let state = create_test_state().await;
|
|
311
|
+
let filter = user_routes(state);
|
|
312
|
+
|
|
313
|
+
let resp = request()
|
|
314
|
+
.method("GET")
|
|
315
|
+
.path("/users/123")
|
|
316
|
+
.reply(&filter)
|
|
317
|
+
.await;
|
|
318
|
+
|
|
319
|
+
assert_eq!(resp.status(), StatusCode::OK);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[tokio::test]
|
|
323
|
+
async fn test_create_user() {
|
|
324
|
+
let state = create_test_state().await;
|
|
325
|
+
let filter = user_routes(state);
|
|
326
|
+
|
|
327
|
+
let resp = request()
|
|
328
|
+
.method("POST")
|
|
329
|
+
.path("/users")
|
|
330
|
+
.header("Content-Type", "application/json")
|
|
331
|
+
.body(r#"{"email":"test@example.com","name":"Test","password":"password123"}"#)
|
|
332
|
+
.reply(&filter)
|
|
333
|
+
.await;
|
|
334
|
+
|
|
335
|
+
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#[tokio::test]
|
|
339
|
+
async fn test_protected_without_auth() {
|
|
340
|
+
let state = create_test_state().await;
|
|
341
|
+
let filter = protected_routes(state).recover(handle_rejection);
|
|
342
|
+
|
|
343
|
+
let resp = request()
|
|
344
|
+
.method("GET")
|
|
345
|
+
.path("/protected")
|
|
346
|
+
.reply(&filter)
|
|
347
|
+
.await;
|
|
348
|
+
|
|
349
|
+
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
\`\`\`
|
|
353
|
+
|
|
354
|
+
## ✅ DO
|
|
355
|
+
- Use filter combinators (\`.and()\`, \`.or()\`, \`.map()\`, \`.and_then()\`)
|
|
356
|
+
- Implement \`Reject\` for custom error types
|
|
357
|
+
- Use \`.recover()\` for centralized error handling
|
|
358
|
+
- Use \`warp::trace\` for request logging
|
|
359
|
+
- Clone state with \`Clone\` trait when composing routes
|
|
360
|
+
|
|
361
|
+
## ❌ DON'T
|
|
362
|
+
- Don't forget to handle all rejection types in recovery
|
|
363
|
+
- Don't use \`.unwrap()\` in handlers
|
|
364
|
+
- Don't create filters that are not \`Clone\` + \`Send\` + \`Sync\`
|
|
365
|
+
- Don't expose internal errors to clients
|